Look at the NanoClaw GitHub repo at https://github.com/jamesjlopez/nanoclaw to find how `additional_mounts` is configured for agent containers.

Specifically I need to know:
1. Where in the codebase is `additional_mounts` read and applied to Docker/container config?
2. Is there a config file (JSON, YAML, env file) on the host where mounts are defined, separate from the DB?
3. How was the initial mount added? Is there an admin UI, a seed script, a migration, or a direct DB insert?
4. What's the exact DB table and column that stores this (I can see it's in the groups config but need the schema)?

Check the Docker/container setup files, any migration scripts, and the groups/config API handlers. Report file paths and relevant code snippets.


I'll help you investigate how `additional_mounts` is configured for agent containers in the NanoClaw repository. Let me start by exploring the codebase systematically.


Bash

cd /workspace && git clone https://github.com/jamesjlopez/nanoclaw.git 2>&1 | head -20


Cloning into 'nanoclaw'...
fatal: unable to access 'https://github.com/jamesjlopez/nanoclaw.git/': server certificate verification failed. CAfile: none CRLfile: none


Let me use the agent browser to fetch the repository information:


Launching skill: agent-browser


Base directory for this skill: /home/node/.claude/skills/agent-browser

# Browser Automation with agent-browser

## Quick start

```bash
agent-browser open <url>        # Navigate to page
agent-browser snapshot -i       # Get interactive elements with refs
agent-browser click @e1         # Click element by ref
agent-browser fill @e2 "text"   # Fill input by ref
agent-browser close             # Close browser
```

## Core workflow

1. Navigate: `agent-browser open <url>`
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
3. Interact using refs from the snapshot
4. Re-snapshot after navigation or significant DOM changes

## Commands

### Navigation

```bash
agent-browser open <url>      # Navigate to URL
agent-browser back            # Go back
agent-browser forward         # Go forward
agent-browser reload          # Reload page
agent-browser close           # Close browser
```

### Snapshot (page analysis)

```bash
agent-browser snapshot            # Full accessibility tree
agent-browser snapshot -i         # Interactive elements only (recommended)
agent-browser snapshot -c         # Compact output
agent-browser snapshot -d 3       # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
```

### Interactions (use @refs from snapshot)

```bash
agent-browser click @e1           # Click
agent-browser dblclick @e1        # Double-click
agent-browser fill @e2 "text"     # Clear and type
agent-browser type @e2 "text"     # Type without clearing
agent-browser press Enter         # Press key
agent-browser hover @e1           # Hover
agent-browser check @e1           # Check checkbox
agent-browser uncheck @e1         # Uncheck checkbox
agent-browser select @e1 "value"  # Select dropdown option
agent-browser scroll down 500     # Scroll page
agent-browser upload @e1 file.pdf # Upload files
```

### Get information

```bash
agent-browser get text @e1        # Get element text
agent-browser get html @e1        # Get innerHTML
agent-browser get value @e1       # Get input value
agent-browser get attr @e1 href   # Get attribute
agent-browser get title           # Get page title
agent-browser get url             # Get current URL
agent-browser get count ".item"   # Count matching elements
```

### Screenshots & PDF

```bash
agent-browser screenshot          # Save to temp directory
agent-browser screenshot path.png # Save to specific path
agent-browser screenshot --full   # Full page
agent-browser pdf output.pdf      # Save as PDF
```

### Wait

```bash
agent-browser wait @e1                     # Wait for element
agent-browser wait 2000                    # Wait milliseconds
agent-browser wait --text "Success"        # Wait for text
agent-browser wait --url "**/dashboard"    # Wait for URL pattern
agent-browser wait --load networkidle      # Wait for network idle
```

### Semantic locators (alternative to refs)

```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find placeholder "Search" type "query"
```

### Authentication with saved state

```bash
# Login once
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "username"
agent-browser fill @e2 "password"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
agent-browser state save auth.json

# Later: load saved state
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```

### Cookies & Storage

```bash
agent-browser cookies                     # Get all cookies
agent-browser cookies set name value      # Set cookie
agent-browser cookies clear               # Clear cookies
agent-browser storage local               # Get localStorage
agent-browser storage local set k v       # Set value
```

### JavaScript

```bash
agent-browser eval "document.title"   # Run JavaScript
```

## Example: Form submission

```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]

agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i  # Check result
```

## Example: Data extraction

```bash
agent-browser open https://example.com/products
agent-browser snapshot -i
agent-browser get text @e1  # Get product title
agent-browser get attr @e2 href  # Get link URL
agent-browser screenshot products.png
```

ARGUMENTS: https://github.com/jamesjlopez/nanoclaw


Let me download the repository directly using a different approach:


cd /tmp && curl -L -o nanoclaw.zip https://github.com/jamesjlopez/nanoclaw/archive/refs/heads/main.zip 2>&1 | tail -5


  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed

  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0

100 12578    0 12578    0     0  17646      0 --:--:-- --:--:-- --:--:-- 17646
100 9620k    0 9620k    0     0  8192k      0 --:--:--  0:00:01 --:--:-- 20.3M


cd /tmp && unzip -q nanoclaw.zip && ls -la | grep nano


drwxr-xr-x 15  501 dialout    4096 May 26 14:46 nanoclaw-main
-rw-r--r--  1  501 dialout 9850917 Jul  6 21:45 nanoclaw.zip


Now let me search for `additional_mounts` in the codebase:


Found 11 files
/tmp/nanoclaw-main/.claude/skills/add-gcal-tool/SKILL.md
/tmp/nanoclaw-main/.claude/skills/add-gmail-tool/SKILL.md
/tmp/nanoclaw-main/.claude/skills/add-rtk/SKILL.md
/tmp/nanoclaw-main/docs/db-central.md
/tmp/nanoclaw-main/src/backfill-container-configs.ts
/tmp/nanoclaw-main/src/cli/resources/groups.test.ts
/tmp/nanoclaw-main/src/cli/resources/groups.ts
/tmp/nanoclaw-main/src/container-config.ts
/tmp/nanoclaw-main/src/db/container-configs.ts
/tmp/nanoclaw-main/src/db/migrations/014-container-configs.ts
/tmp/nanoclaw-main/src/types.ts


Excellent! Now let me read the key files to understand the configuration:


1	/**
2	 * Container config types and materialization.
3	 *
4	 * Source of truth is the `container_configs` table in the central DB.
5	 * This module provides:
6	 *   - Type definitions for the file shape (read by the container runner)
7	 *   - `materializeContainerJson()` — writes `groups/<folder>/container.json`
8	 *     from the DB at spawn time
9	 *   - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group
10	 */
11	import fs from 'fs';
12	import path from 'path';
13
14	import { GROUPS_DIR } from './config.js';
15	import { getContainerConfig } from './db/container-configs.js';
16	import { getAgentGroup } from './db/agent-groups.js';
17	import type { AgentGroup, ContainerConfigRow } from './types.js';
18
19	export interface McpServerConfig {
20	  command: string;
21	  args?: string[];
22	  env?: Record<string, string>;
23	  instructions?: string;
24	}
25
26	export interface AdditionalMountConfig {
27	  hostPath: string;
28	  containerPath: string;
29	  readonly?: boolean;
30	}
31
32	/** Shape of the materialized `container.json` file read by the container runner. */
33	export interface ContainerConfig {
34	  mcpServers: Record<string, McpServerConfig>;
35	  packages: { apt: string[]; npm: string[] };
36	  imageTag?: string;
37	  additionalMounts: AdditionalMountConfig[];
38	  skills: string[] | 'all';
39	  provider?: string;
40	  groupName?: string;
41	  assistantName?: string;
42	  agentGroupId?: string;
43	  maxMessagesPerPrompt?: number;
44	  model?: string;
45	  effort?: string;
46	}
47
48	/** Build a `ContainerConfig` from a DB row + agent group identity. */
49	export function configFromDb(row: ContainerConfigRow, group: AgentGroup): ContainerConfig {
50	  return {
51	    mcpServers: JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>,
52	    packages: {
53	      apt: JSON.parse(row.packages_apt) as string[],
54	      npm: JSON.parse(row.packages_npm) as string[],
55	    },
56	    imageTag: row.image_tag ?? undefined,
57	    additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[],
58	    skills: JSON.parse(row.skills) as string[] | 'all',
59	    provider: row.provider ?? undefined,
60	    groupName: group.name,
61	    assistantName: row.assistant_name ?? group.name,
62	    agentGroupId: group.id,
63	    maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
64	    model: row.model ?? undefined,
65	    effort: row.effort ?? undefined,
66	  };
67	}
68
69	/**
70	 * Materialize `container.json` from the DB. Called at spawn time so the
71	 * container always sees fresh config. Returns the `ContainerConfig` for
72	 * use by the caller (buildMounts, buildContainerArgs, etc.).
73	 */
74	export function materializeContainerJson(agentGroupId: string): ContainerConfig {
75	  const group = getAgentGroup(agentGroupId);
76	  if (!group) throw new Error(`Agent group not found: ${agentGroupId}`);
77
78	  const row = getContainerConfig(agentGroupId);
79	  if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`);
80
81	  const config = configFromDb(row, group);
82
83	  const p = path.join(GROUPS_DIR, group.folder, 'container.json');
84	  const dir = path.dirname(p);
85	  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
86	  fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
87
88	  return config;
89	}
90


1	import type { ContainerConfigRow } from '../types.js';
2	import { getDb } from './connection.js';
3
4	const SCALAR_COLUMNS = new Set([
5	  'provider',
6	  'model',
7	  'effort',
8	  'image_tag',
9	  'assistant_name',
10	  'max_messages_per_prompt',
11	  'cli_scope',
12	]);
13	const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
14
15	export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
16	  return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
17	    | ContainerConfigRow
18	    | undefined;
19	}
20
21	export function getAllContainerConfigs(): ContainerConfigRow[] {
22	  return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[];
23	}
24
25	/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */
26	export function createContainerConfig(config: ContainerConfigRow): void {
27	  getDb()
28	    .prepare(
29	      `INSERT INTO container_configs (
30	        agent_group_id, provider, model, effort, image_tag, assistant_name,
31	        max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
32	        additional_mounts, updated_at
33	      ) VALUES (
34	        @agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
35	        @max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
36	        @additional_mounts, @updated_at
37	      )`,
38	    )
39	    .run(config);
40	}
41
42	/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
43	export function ensureContainerConfig(agentGroupId: string): void {
44	  getDb()
45	    .prepare(
46	      `INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
47	       VALUES (?, ?)`,
48	    )
49	    .run(agentGroupId, new Date().toISOString());
50	}
51
52	/** Update scalar fields on a config row. Only touches fields present in `updates`. */
53	export function updateContainerConfigScalars(
54	  agentGroupId: string,
55	  updates: Partial<
56	    Pick<
57	      ContainerConfigRow,
58	      'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
59	    >
60	  >,
61	): void {
62	  const fields: string[] = [];
63	  const values: Record<string, unknown> = { agent_group_id: agentGroupId };
64
65	  for (const [key, value] of Object.entries(updates)) {
66	    if (value !== undefined) {
67	      if (!SCALAR_COLUMNS.has(key)) throw new Error(`Invalid scalar column: ${key}`);
68	      fields.push(`${key} = @${key}`);
69	      values[key] = value;
70	    }
71	  }
72	  if (fields.length === 0) return;
73
74	  fields.push('updated_at = @updated_at');
75	  values.updated_at = new Date().toISOString();
76
77	  getDb()
78	    .prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`)
79	    .run(values);
80	}
81
82	/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
83	export function updateContainerConfigJson(
84	  agentGroupId: string,
85	  column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
86	  value: unknown,
87	): void {
88	  if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
89	  const now = new Date().toISOString();
90	  getDb()
91	    .prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`)
92	    .run(JSON.stringify(value), now, agentGroupId);
93	}
94
95	export function deleteContainerConfig(agentGroupId: string): void {
96	  getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId);
97	}
98


1	import type Database from 'better-sqlite3';
2	import type { Migration } from './index.js';
3
4	export const migration014: Migration = {
5	  version: 14,
6	  name: 'container-configs',
7	  up(db: Database.Database) {
8	    db.exec(`
9	      CREATE TABLE container_configs (
10	        agent_group_id        TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
11	        provider              TEXT,
12	        model                 TEXT,
13	        effort                TEXT,
14	        image_tag             TEXT,
15	        assistant_name        TEXT,
16	        max_messages_per_prompt INTEGER,
17	        skills                TEXT NOT NULL DEFAULT '"all"',
18	        mcp_servers           TEXT NOT NULL DEFAULT '{}',
19	        packages_apt          TEXT NOT NULL DEFAULT '[]',
20	        packages_npm          TEXT NOT NULL DEFAULT '[]',
21	        additional_mounts     TEXT NOT NULL DEFAULT '[]',
22	        updated_at            TEXT NOT NULL
23	      );
24	    `);
25	  },
26	};
27


1	import type { McpServerConfig } from '../../container-config.js';
2	import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
3	import { restartAgentGroupContainers } from '../../container-restart.js';
4	import { getDb, hasTable } from '../../db/connection.js';
5	import { getSession } from '../../db/sessions.js';
6	import { writeSessionMessage } from '../../session-manager.js';
7	import {
8	  getContainerConfig,
9	  updateContainerConfigScalars,
10	  updateContainerConfigJson,
11	} from '../../db/container-configs.js';
12	import type { ContainerConfigRow } from '../../types.js';
13	import { registerResource } from '../crud.js';
14
15	/** Deserialize JSON columns for display. */
16	function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
17	  return {
18	    agent_group_id: row.agent_group_id,
19	    provider: row.provider,
20	    model: row.model,
21	    effort: row.effort,
22	    image_tag: row.image_tag,
23	    assistant_name: row.assistant_name,
24	    max_messages_per_prompt: row.max_messages_per_prompt,
25	    skills: JSON.parse(row.skills),
26	    mcp_servers: JSON.parse(row.mcp_servers),
27	    packages_apt: JSON.parse(row.packages_apt),
28	    packages_npm: JSON.parse(row.packages_npm),
29	    additional_mounts: JSON.parse(row.additional_mounts),
30	    cli_scope: row.cli_scope,
31	    updated_at: row.updated_at,
32	  };
33	}
34
35	registerResource({
36	  name: 'group',
37	  plural: 'groups',
38	  table: 'agent_groups',
39	  description:
40	    'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.',
41	  idColumn: 'id',
42	  scopeField: 'id',
43	  columns: [
44	    { name: 'id', type: 'string', description: 'UUID.', generated: true },
45	    {
46	      name: 'name',
47	      type: 'string',
48	      description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.',
49	      required: true,
50	      updatable: true,
51	    },
52	    {
53	      name: 'folder',
54	      type: 'string',
55	      description:
56	        'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.',
57	      required: true,
58	    },
59	    { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
60	  ],
61	  // `delete` is intentionally not in `operations` — the generic single-table
62	  // DELETE violates FK constraints (see #2525). The cascading handler is
63	  // provided as `customOperations.delete` below.
64	  operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
65	  customOperations: {
66	    delete: {
67	      access: 'approval',
68	      description:
69	        'Delete an agent group and its dependent rows (sessions, destinations, approvals, role grants, ' +
70	        'memberships, channel wirings). FK-ordered cascade in a single transaction. ' +
71	        'Use --id <group-id>. Out of scope: killing running containers, on-disk cleanup of groups/<folder>/ and data/v2-sessions/<group-id>/.',
72	      handler: async (args) => {
73	        const id = args.id as string;
74	        if (!id) throw new Error('--id is required');
75	        const db = getDb();
76
77	        // Verify the group exists before doing anything — preserves the
78	        // genericDelete behaviour of throwing "not found" for unknown IDs.
79	        const exists = db.prepare('SELECT 1 FROM agent_groups WHERE id = ? LIMIT 1').get(id);
80	        if (!exists) throw new Error(`group not found: ${id}`);
81
82	        const hasAgentDestinations = hasTable(db, 'agent_destinations');
83	        const hasPendingApprovals = hasTable(db, 'pending_approvals');
84
85	        // FK-ordered cascade. Single sync transaction — better-sqlite3 rolls
86	        // back the whole thing if any statement throws (e.g. an FK constraint
87	        // we missed), so the central DB stays consistent. The `removed` counts
88	        // are sourced from each DELETE's `changes` so they describe exactly
89	        // what the transaction did, not a separate pre-flight snapshot.
90	        const cascade = db.transaction((groupId: string) => {
91	          const counts = {
92	            sessions: 0,
93	            pending_questions: 0,
94	            pending_approvals: 0,
95	            agent_destinations_owned: 0,
96	            agent_destinations_pointing: 0,
97	            pending_sender_approvals: 0,
98	            pending_channel_approvals: 0,
99	            messaging_group_agents: 0,
100	            agent_group_members: 0,
101	            user_roles: 0,
102	            container_configs: 0,
103	          };
104
105	          if (hasAgentDestinations) {
106	            counts.agent_destinations_owned = db
107	              .prepare('DELETE FROM agent_destinations WHERE agent_group_id = ?')
108	              .run(groupId).changes;
109	            counts.agent_destinations_pointing = db
110	              .prepare('DELETE FROM agent_destinations WHERE target_type = ? AND target_id = ?')
111	              .run('agent', groupId).changes;
112	          }
113	          counts.pending_questions = db
114	            .prepare(
115	              'DELETE FROM pending_questions WHERE session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)',
116	            )
117	            .run(groupId).changes;
118	          if (hasPendingApprovals) {
119	            counts.pending_approvals = db
120	              .prepare(
121	                'DELETE FROM pending_approvals WHERE agent_group_id = ? OR session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)',
122	              )
123	              .run(groupId, groupId).changes;
124	          }
125	          counts.sessions = db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(groupId).changes;
126	          counts.pending_sender_approvals = db
127	            .prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?')
128	            .run(groupId).changes;
129	          counts.pending_channel_approvals = db
130	            .prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?')
131	            .run(groupId).changes;
132	          counts.messaging_group_agents = db
133	            .prepare('DELETE FROM messaging_group_agents WHERE agent_group_id = ?')
134	            .run(groupId).changes;
135	          counts.agent_group_members = db
136	            .prepare('DELETE FROM agent_group_members WHERE agent_group_id = ?')
137	            .run(groupId).changes;
138	          counts.user_roles = db.prepare('DELETE FROM user_roles WHERE agent_group_id = ?').run(groupId).changes;
139	          // migration-014 has ON DELETE CASCADE on container_configs.agent_group_id;
140	          // the explicit delete here mirrors the other tables and surfaces the count.
141	          counts.container_configs = db
142	            .prepare('DELETE FROM container_configs WHERE agent_group_id = ?')
143	            .run(groupId).changes;
144	          db.prepare('DELETE FROM agent_groups WHERE id = ?').run(groupId);
145	          return counts;
146	        });
147	        const removed = cascade(id);
148
149	        return { deleted: id, removed };
150	      },
151	    },
152	    restart: {
153	      access: 'approval',
154	      description:
155	        'Restart containers for a group. Use --id <group-id> [--rebuild] [--message <text>]. ' +
156	        'From inside a container, --id is auto-filled and only the calling session is restarted. ' +
157	        '--rebuild rebuilds the container image first (required for package changes). ' +
158	        '--message sets an on-wake instruction for the fresh container to act on when it starts — ' +
159	        'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' +
160	        'Without --message, the container stops and only starts again on the next user message.',
161	      handler: async (args, ctx) => {
162	        const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined);
163	        if (!id) throw new Error('--id is required');
164	        if (args.rebuild) {
165	          await buildAgentGroupImage(id);
166	        }
167	        const message = args.message as string | undefined;
168
169	        // From an agent: scope to the calling session only
170	        if (ctx.caller === 'agent') {
171	          if (message) {
172	            writeSessionMessage(id, ctx.sessionId, {
173	              id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
174	              kind: 'chat',
175	              timestamp: new Date().toISOString(),
176	              platformId: id,
177	              channelType: 'agent',
178	              threadId: null,
179	              content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }),
180	              onWake: 1,
181	            });
182	          }
183	          killContainer(
184	            ctx.sessionId,
185	            'restarted via ncl',
186	            message
187	              ? () => {
188	                  const s = getSession(ctx.sessionId);
189	                  if (s) wakeContainer(s);
190	                }
191	              : undefined,
192	          );
193	          return { restarted: 1, rebuilt: !!args.rebuild };
194	        }
195
196	        // From the host: restart all running containers in the group
197	        const count = restartAgentGroupContainers(id, 'restarted via ncl', message);
198	        return { restarted: count, rebuilt: !!args.rebuild };
199	      },
200	    },
201	    'config get': {
202	      access: 'open',
203	      description: 'Show the container config for a group. Use --id <group-id>.',
204	      handler: async (args) => {
205	        const id = args.id as string;
206	        if (!id) throw new Error('--id is required');
207	        const row = getContainerConfig(id);
208	        if (!row) throw new Error(`No container config for group: ${id}`);
209	        return presentConfig(row);
210	      },
211	    },
212	    'config update': {
213	      access: 'approval',
214	      description:
215	        'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
216	        'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
217	      handler: async (args) => {
218	        const id = args.id as string;
219	        if (!id) throw new Error('--id is required');
220	        const row = getContainerConfig(id);
221	        if (!row) throw new Error(`No container config for group: ${id}`);
222
223	        const updates: Partial<
224	          Pick<
225	            ContainerConfigRow,
226	            'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
227	          >
228	        > = {};
229	        if (args.provider !== undefined) updates.provider = args.provider as string;
230	        if (args.model !== undefined) updates.model = args.model as string;
231	        if (args.effort !== undefined) updates.effort = args.effort as string;
232	        if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string;
233	        if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
234	        if (args.max_messages_per_prompt !== undefined)
235	          updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
236	        if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
237	          const scope = (args['cli-scope'] ?? args.cli_scope) as string;
238	          if (!['disabled', 'group', 'global'].includes(scope)) {
239	            throw new Error('--cli-scope must be one of: disabled, group, global');
240	          }
241	          updates.cli_scope = scope;
242	        }
243
244	        if (Object.keys(updates).length === 0) {
245	          throw new Error(
246	            'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
247	          );
248	        }
249
250	        updateContainerConfigScalars(id, updates);
251
252	        const updated = getContainerConfig(id)!;
253	        return presentConfig(updated);
254	      },
255	    },
256	    'config add-mcp-server': {
257	      access: 'approval',
258	      description:
259	        'Add an MCP server to a group. Requires `ncl groups restart` to take effect. ' +
260	        'Use --id <group-id> --name <server-name> --command <cmd> [--args <json-array>] [--env <json-object>].',
261	      handler: async (args) => {
262	        const id = args.id as string;
263	        if (!id) throw new Error('--id is required');
264	        const name = args.name as string;
265	        if (!name) throw new Error('--name is required');
266	        const command = args.command as string;
267	        if (!command) throw new Error('--command is required');
268
269	        const row = getContainerConfig(id);
270	        if (!row) throw new Error(`No container config for group: ${id}`);
271
272	        const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
273	        servers[name] = {
274	          command,
275	          args: args.args ? (JSON.parse(args.args as string) as string[]) : [],
276	          env: args.env ? (JSON.parse(args.env as string) as Record<string, string>) : {},
277	        };
278	        updateContainerConfigJson(id, 'mcp_servers', servers);
279
280	        return { added: name, servers };
281	      },
282	    },
283	    'config remove-mcp-server': {
284	      access: 'approval',
285	      description:
286	        'Remove an MCP server from a group. Requires `ncl groups restart` to take effect. Use --id <group-id> --name <server-name>.',
287	      handler: async (args) => {
288	        const id = args.id as string;
289	        if (!id) throw new Error('--id is required');
290	        const name = args.name as string;
291	        if (!name) throw new Error('--name is required');
292
293	        const row = getContainerConfig(id);
294	        if (!row) throw new Error(`No container config for group: ${id}`);
295
296	        const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
297	        if (!servers[name]) throw new Error(`MCP server "${name}" not found`);
298	        delete servers[name];
299	        updateContainerConfigJson(id, 'mcp_servers', servers);
300
301	        return { removed: name };
302	      },
303	    },
304	    'config add-package': {
305	      access: 'approval',
306	      description:
307	        'Add a package to a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
308	      handler: async (args) => {
309	        const id = args.id as string;
310	        if (!id) throw new Error('--id is required');
311
312	        const row = getContainerConfig(id);
313	        if (!row) throw new Error(`No container config for group: ${id}`);
314
315	        const apt = args.apt as string | undefined;
316	        const npm = args.npm as string | undefined;
317	        if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
318
319	        if (apt) {
320	          const existing = JSON.parse(row.packages_apt) as string[];
321	          if (!existing.includes(apt)) {
322	            existing.push(apt);
323	            updateContainerConfigJson(id, 'packages_apt', existing);
324	          }
325	        }
326	        if (npm) {
327	          const existing = JSON.parse(row.packages_npm) as string[];
328	          if (!existing.includes(npm)) {
329	            existing.push(npm);
330	            updateContainerConfigJson(id, 'packages_npm', existing);
331	          }
332	        }
333
334	        return {
335	          added: { apt: apt || null, npm: npm || null },
336	          note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.',
337	        };
338	      },
339	    },
340	    'config remove-package': {
341	      access: 'approval',
342	      description:
343	        'Remove a package from a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
344	      handler: async (args) => {
345	        const id = args.id as string;
346	        if (!id) throw new Error('--id is required');
347
348	        const row = getContainerConfig(id);
349	        if (!row) throw new Error(`No container config for group: ${id}`);
350
351	        const apt = args.apt as string | undefined;
352	        const npm = args.npm as string | undefined;
353	        if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
354
355	        if (apt) {
356	          const existing = JSON.parse(row.packages_apt) as string[];
357	          const filtered = existing.filter((p) => p !== apt);
358	          updateContainerConfigJson(id, 'packages_apt', filtered);
359	        }
360	        if (npm) {
361	          const existing = JSON.parse(row.packages_npm) as string[];
362	          const filtered = existing.filter((p) => p !== npm);
363	          updateContainerConfigJson(id, 'packages_npm', filtered);
364	        }
365
366	        return {
367	          removed: { apt: apt || null, npm: npm || null },
368	          note: 'Image rebuild required for package changes to take effect.',
369	        };
370	      },
371	    },
372	  },
373	});
374


1	/**
2	 * One-time backfill: seed `container_configs` rows from existing
3	 * `groups/<folder>/container.json` files and `agent_groups.agent_provider`.
4	 *
5	 * Runs after migrations, before channel adapters start. Idempotent — skips
6	 * groups that already have a config row.
7	 */
8	import fs from 'fs';
9	import path from 'path';
10
11	import { GROUPS_DIR } from './config.js';
12	import type { McpServerConfig, AdditionalMountConfig } from './container-config.js';
13	import { getAllAgentGroups } from './db/agent-groups.js';
14	import { getContainerConfig, createContainerConfig } from './db/container-configs.js';
15	import { log } from './log.js';
16	import type { ContainerConfigRow } from './types.js';
17
18	interface LegacyContainerJson {
19	  mcpServers?: Record<string, McpServerConfig>;
20	  packages?: { apt?: string[]; npm?: string[] };
21	  imageTag?: string;
22	  additionalMounts?: AdditionalMountConfig[];
23	  skills?: string[] | 'all';
24	  provider?: string;
25	  assistantName?: string;
26	  maxMessagesPerPrompt?: number;
27	}
28
29	export function backfillContainerConfigs(): void {
30	  const groups = getAllAgentGroups();
31	  let backfilled = 0;
32
33	  for (const group of groups) {
34	    // Skip if already has a config row
35	    if (getContainerConfig(group.id)) continue;
36
37	    // Read legacy container.json from disk
38	    const filePath = path.join(GROUPS_DIR, group.folder, 'container.json');
39	    let legacy: LegacyContainerJson = {};
40	    if (fs.existsSync(filePath)) {
41	      try {
42	        legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson;
43	      } catch (err) {
44	        log.warn('Backfill: failed to parse container.json, using defaults', {
45	          folder: group.folder,
46	          err: String(err),
47	        });
48	      }
49	    }
50
51	    // DB agent_provider wins over file provider (matches old cascade)
52	    const provider = group.agent_provider || legacy.provider || null;
53
54	    const row: ContainerConfigRow = {
55	      agent_group_id: group.id,
56	      provider,
57	      model: null,
58	      effort: null,
59	      image_tag: legacy.imageTag ?? null,
60	      assistant_name: legacy.assistantName ?? null,
61	      max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
62	      skills: JSON.stringify(legacy.skills ?? 'all'),
63	      mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
64	      packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
65	      packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
66	      additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
67	      cli_scope: 'group',
68	      updated_at: new Date().toISOString(),
69	    };
70
71	    createContainerConfig(row);
72	    backfilled++;
73	  }
74
75	  if (backfilled > 0) {
76	    log.info('Backfilled container_configs from disk', { count: backfilled });
77	  }
78	}
79


Now let me search for where the mounts are actually applied to the Docker configuration:


Found 12 files
/tmp/nanoclaw-main/.claude/skills/add-gcal-tool/SKILL.md
/tmp/nanoclaw-main/.claude/skills/add-gmail-tool/SKILL.md
/tmp/nanoclaw-main/.claude/skills/migrate-from-v1/SKILL.md
/tmp/nanoclaw-main/docs/architecture.md
/tmp/nanoclaw-main/docs/migration-dev.md
/tmp/nanoclaw-main/docs/SPEC.md
/tmp/nanoclaw-main/setup/migrate-v2/groups.ts
/tmp/nanoclaw-main/src/backfill-container-configs.ts
/tmp/nanoclaw-main/src/claude-md-compose.ts
/tmp/nanoclaw-main/src/container-config.ts
/tmp/nanoclaw-main/src/container-runner.ts
/tmp/nanoclaw-main/src/group-init.ts


1	/**
2	 * Container Runner v2
3	 * Spawns agent containers with session folder + agent group folder mounts.
4	 * The container runs the v2 agent-runner which polls the session DB.
5	 */
6	import { ChildProcess, execSync, spawn } from 'child_process';
7	import fs from 'fs';
8	import path from 'path';
9
10	import { OneCLI } from '@onecli-sh/sdk';
11
12	import {
13	  CONTAINER_IMAGE,
14	  CONTAINER_IMAGE_BASE,
15	  CONTAINER_INSTALL_LABEL,
16	  DATA_DIR,
17	  GROUPS_DIR,
18	  ONECLI_API_KEY,
19	  ONECLI_URL,
20	  TIMEZONE,
21	} from './config.js';
22	import { materializeContainerJson } from './container-config.js';
23	import { getContainerConfig } from './db/container-configs.js';
24	import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js';
25	import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
26	import { composeGroupClaudeMd } from './claude-md-compose.js';
27	import { getAgentGroup } from './db/agent-groups.js';
28	import { getDb, hasTable } from './db/connection.js';
29	import { initGroupFilesystem } from './group-init.js';
30	import { stopTypingRefresh } from './modules/typing/index.js';
31	import { log } from './log.js';
32	import { validateAdditionalMounts } from './modules/mount-security/index.js';
33	// Provider host-side config barrel — each provider that needs host-side
34	// container setup self-registers on import.
35	import './providers/index.js';
36	import {
37	  getProviderContainerConfig,
38	  type ProviderContainerContribution,
39	  type VolumeMount,
40	} from './providers/provider-container-registry.js';
41	import {
42	  heartbeatPath,
43	  markContainerRunning,
44	  markContainerStopped,
45	  sessionDir,
46	  writeSessionRouting,
47	} from './session-manager.js';
48	import type { AgentGroup, Session } from './types.js';
49
50	const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY });
51
52	/** Active containers tracked by session ID. */
53	const activeContainers = new Map<string, { process: ChildProcess; containerName: string }>();
54
55	/**
56	 * In-flight wake promises, keyed by session id. Deduplicates concurrent
57	 * `wakeContainer` calls while the first spawn is still mid-setup (async
58	 * buildContainerArgs, OneCLI gateway apply, etc.) — otherwise a second
59	 * wake in that window passes the `activeContainers.has` check and spawns
60	 * a duplicate container against the same session directory, producing
61	 * racy double-replies.
62	 */
63	const wakePromises = new Map<string, Promise<boolean>>();
64
65	export function getActiveContainerCount(): number {
66	  return activeContainers.size;
67	}
68
69	export function isContainerRunning(sessionId: string): boolean {
70	  return activeContainers.has(sessionId);
71	}
72
73	/**
74	 * Wake up a container for a session. If already running or mid-spawn, no-op
75	 * (the in-flight wake promise is reused).
76	 *
77	 * The container runs the v2 agent-runner which polls the session DB.
78	 *
79	 * Contract: never throws. Returns `true` on successful spawn, `false` on
80	 * transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't
81	 * need to wrap — the inbound row stays pending and host-sweep retries on
82	 * its next tick. Callers that care (e.g. the router's typing indicator)
83	 * can branch on the boolean.
84	 */
85	export function wakeContainer(session: Session): Promise<boolean> {
86	  if (activeContainers.has(session.id)) {
87	    log.debug('Container already running', { sessionId: session.id });
88	    return Promise.resolve(true);
89	  }
90	  const existing = wakePromises.get(session.id);
91	  if (existing) {
92	    log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id });
93	    return existing;
94	  }
95	  const promise = spawnContainer(session)
96	    .then(() => true)
97	    .catch((err) => {
98	      log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err });
99	      return false;
100	    })
101	    .finally(() => {
102	      wakePromises.delete(session.id);
103	    });
104	  wakePromises.set(session.id, promise);
105	  return promise;
106	}
107
108	async function spawnContainer(session: Session): Promise<void> {
109	  const agentGroup = getAgentGroup(session.agent_group_id);
110	  if (!agentGroup) {
111	    log.error('Agent group not found', { agentGroupId: session.agent_group_id });
112	    return;
113	  }
114
115	  // Refresh the destination map and default reply routing so any admin
116	  // changes take effect on wake. Destinations come from the agent-to-agent
117	  // module — skip when the module isn't installed (table absent).
118	  if (hasTable(getDb(), 'agent_destinations')) {
119	    const { writeDestinations } = await import('./modules/agent-to-agent/write-destinations.js');
120	    writeDestinations(agentGroup.id, session.id);
121	  }
122	  writeSessionRouting(agentGroup.id, session.id);
123
124	  // Materialize container.json from DB — writes fresh file and returns
125	  // the config object, threaded through provider resolution, buildMounts,
126	  // and buildContainerArgs so we don't re-read.
127	  const containerConfig = materializeContainerJson(agentGroup.id);
128
129	  // Resolve the effective provider + any host-side contribution it declares
130	  // (extra mounts, env passthrough). Computed once and threaded through both
131	  // buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
132	  const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
133
134	  const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
135	  const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
136	  // OneCLI agent identifier is always the agent group id — stable across
137	  // sessions and reversible via getAgentGroup() for approval routing.
138	  const agentIdentifier = agentGroup.id;
139	  const args = await buildContainerArgs(
140	    mounts,
141	    containerName,
142	    agentGroup,
143	    containerConfig,
144	    provider,
145	    contribution,
146	    agentIdentifier,
147	  );
148
149	  log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName });
150
151	  // Clear any orphan heartbeat from a previous container instance — the
152	  // sweep's ceiling check treats a missing file as "fresh spawn, give grace"
153	  // (host-sweep.ts line 87). Without this, the stale mtime can trigger an
154	  // immediate kill before the new container touches the file itself.
155	  fs.rmSync(heartbeatPath(agentGroup.id, session.id), { force: true });
156
157	  const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
158
159	  activeContainers.set(session.id, { process: container, containerName });
160	  markContainerRunning(session.id);
161
162	  // Log stderr
163	  container.stderr?.on('data', (data) => {
164	    for (const line of data.toString().trim().split('\n')) {
165	      if (line) log.debug(line, { container: agentGroup.folder });
166	    }
167	  });
168
169	  // stdout is unused in v2 (all IO is via session DB)
170	  container.stdout?.on('data', () => {});
171
172	  // No host-side idle timeout. Stale/stuck detection is driven by the host
173	  // sweep reading heartbeat mtime + processing_ack claim age + container_state
174	  // (see src/host-sweep.ts). This avoids killing long-running legitimate work
175	  // on a wall-clock timer.
176
177	  container.on('close', (code) => {
178	    activeContainers.delete(session.id);
179	    markContainerStopped(session.id);
180	    stopTypingRefresh(session.id);
181	    log.info('Container exited', { sessionId: session.id, code, containerName });
182	  });
183
184	  container.on('error', (err) => {
185	    activeContainers.delete(session.id);
186	    markContainerStopped(session.id);
187	    stopTypingRefresh(session.id);
188	    log.error('Container spawn error', { sessionId: session.id, err });
189	  });
190	}
191
192	/** Kill a container for a session. */
193	export function killContainer(sessionId: string, reason: string, onExit?: () => void): void {
194	  const entry = activeContainers.get(sessionId);
195	  if (!entry) return;
196
197	  if (onExit) {
198	    entry.process.once('close', onExit);
199	  }
200


200
201	  log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
202	  try {
203	    stopContainer(entry.containerName);
204	  } catch {
205	    entry.process.kill('SIGKILL');
206	  }
207	}
208
209	/**
210	 * Resolve the provider name for a session:
211	 *
212	 *   sessions.agent_provider
213	 *     → container_configs.provider
214	 *     → 'claude'
215	 *
216	 * Pure so the precedence can be unit-tested without a DB or filesystem.
217	 */
218	export function resolveProviderName(
219	  sessionProvider: string | null | undefined,
220	  containerConfigProvider: string | null | undefined,
221	): string {
222	  return (sessionProvider || containerConfigProvider || 'claude').toLowerCase();
223	}
224
225	function resolveProviderContribution(
226	  session: Session,
227	  agentGroup: AgentGroup,
228	  containerConfig: import('./container-config.js').ContainerConfig,
229	): { provider: string; contribution: ProviderContainerContribution } {
230	  const provider = resolveProviderName(session.agent_provider, containerConfig.provider);
231	  const fn = getProviderContainerConfig(provider);
232	  const contribution = fn
233	    ? fn({
234	        sessionDir: sessionDir(agentGroup.id, session.id),
235	        agentGroupId: agentGroup.id,
236	        hostEnv: process.env,
237	      })
238	    : {};
239	  return { provider, contribution };
240	}
241
242	function buildMounts(
243	  agentGroup: AgentGroup,
244	  session: Session,
245	  containerConfig: import('./container-config.js').ContainerConfig,
246	  providerContribution: ProviderContainerContribution,
247	): VolumeMount[] {
248	  const projectRoot = process.cwd();
249
250	  // Per-group filesystem state lives forever after first creation. Init is
251	  // idempotent: it only writes paths that don't already exist, so this call
252	  // is a no-op for groups that have spawned before.
253	  initGroupFilesystem(agentGroup);
254
255	  // Sync skill symlinks based on container.json selection before mounting.
256	  const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
257	  syncSkillSymlinks(claudeDir, containerConfig);
258
259	  // Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
260	  // fragments, and MCP server instructions. See `claude-md-compose.ts`.
261	  composeGroupClaudeMd(agentGroup);
262
263	  const mounts: VolumeMount[] = [];
264	  const sessDir = sessionDir(agentGroup.id, session.id);
265	  const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
266
267	  // Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/)
268	  mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false });
269
270	  // Agent group folder at /workspace/agent (RW for working files + CLAUDE.local.md)
271	  mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false });
272
273	  // container.json — nested RO mount on top of RW group dir so the agent
274	  // can read its config but cannot modify it.
275	  const containerJsonPath = path.join(groupDir, 'container.json');
276	  if (fs.existsSync(containerJsonPath)) {
277	    mounts.push({ hostPath: containerJsonPath, containerPath: '/workspace/agent/container.json', readonly: true });
278	  }
279
280	  // Composer-managed CLAUDE.md artifacts — nested RO mounts. These are
281	  // regenerated from the shared base + fragments on every spawn; any
282	  // agent-side writes would be clobbered, so enforce read-only. Only
283	  // CLAUDE.local.md (per-group memory) remains RW via the group-dir mount.
284	  // `.claude-shared.md` is a symlink whose target (`/app/CLAUDE.md`) is
285	  // already RO-mounted, so writes through it fail regardless — no need for
286	  // a nested mount there.
287	  const composedClaudeMd = path.join(groupDir, 'CLAUDE.md');
288	  if (fs.existsSync(composedClaudeMd)) {
289	    mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true });
290	  }
291	  const fragmentsDir = path.join(groupDir, '.claude-fragments');
292	  if (fs.existsSync(fragmentsDir)) {
293	    mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
294	  }
295
296	  // Global memory directory — always read-only.
297	  const globalDir = path.join(GROUPS_DIR, 'global');
298	  if (fs.existsSync(globalDir)) {
299	    mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
300	  }
301
302	  // Shared CLAUDE.md — read-only, imported by the composed entry point via
303	  // the `.claude-shared.md` symlink inside the group dir.
304	  const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
305	  if (fs.existsSync(sharedClaudeMd)) {
306	    mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
307	  }
308
309	  // Per-group .claude-shared at /home/node/.claude (Claude state, settings,
310	  // skill symlinks)
311	  mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
312
313	  // Shared agent-runner source — read-only, same code for all groups.
314	  const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
315	  mounts.push({ hostPath: agentRunnerSrc, containerPath: '/app/src', readonly: true });
316
317	  // Shared skills — read-only, symlinks in .claude-shared/skills/ point here.
318	  const skillsSrc = path.join(projectRoot, 'container', 'skills');
319	  if (fs.existsSync(skillsSrc)) {
320	    mounts.push({ hostPath: skillsSrc, containerPath: '/app/skills', readonly: true });
321	  }
322
323	  // Additional mounts from container config
324	  if (containerConfig.additionalMounts && containerConfig.additionalMounts.length > 0) {
325	    const validated = validateAdditionalMounts(containerConfig.additionalMounts, agentGroup.name);
326	    mounts.push(...validated);
327	  }
328
329	  // Provider-contributed mounts (e.g. opencode-xdg)
330	  if (providerContribution.mounts) {
331	    mounts.push(...providerContribution.mounts);
332	  }
333
334	  return mounts;
335	}
336
337	/**
338	 * Sync skill symlinks in .claude-shared/skills/ to match the container.json
339	 * selection. Each symlink points to a container path (/app/skills/<name>)
340	 * so it's dangling on the host but valid inside the container.
341	 */
342	function syncSkillSymlinks(claudeDir: string, containerConfig: import('./container-config.js').ContainerConfig): void {
343	  const skillsDir = path.join(claudeDir, 'skills');
344	  if (!fs.existsSync(skillsDir)) {
345	    fs.mkdirSync(skillsDir, { recursive: true });
346	  }
347
348	  // Determine desired skill set
349	  const projectRoot = process.cwd();
350	  const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
351	  let desired: string[];
352	  if (containerConfig.skills === 'all') {
353	    // Recompute from shared dir — newly-added upstream skills appear automatically
354	    desired = fs.existsSync(sharedSkillsDir)
355	      ? fs.readdirSync(sharedSkillsDir).filter((e) => {
356	          try {
357	            return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
358	          } catch {
359	            return false;
360	          }
361	        })
362	      : [];
363	  } else {
364	    desired = containerConfig.skills;
365	  }
366
367	  const desiredSet = new Set(desired);
368
369	  // Remove symlinks not in the desired set
370	  for (const entry of fs.readdirSync(skillsDir)) {
371	    const entryPath = path.join(skillsDir, entry);
372	    let isSymlink = false;
373	    try {
374	      isSymlink = fs.lstatSync(entryPath).isSymbolicLink();
375	    } catch {
376	      continue;
377	    }
378	    if (isSymlink && !desiredSet.has(entry)) {
379	      fs.unlinkSync(entryPath);
380	    }
381	  }
382
383	  // Create symlinks for desired skills (container path targets)
384	  for (const skill of desired) {
385	    const linkPath = path.join(skillsDir, skill);
386	    let exists = false;
387	    try {
388	      fs.lstatSync(linkPath);
389	      exists = true;
390	    } catch {
391	      /* missing */
392	    }
393	    if (!exists) {
394	      fs.symlinkSync(`/app/skills/${skill}`, linkPath);
395	    }
396	  }
397	}
398
399	async function buildContainerArgs(
400	  mounts: VolumeMount[],
401	  containerName: string,
402	  agentGroup: AgentGroup,
403	  containerConfig: import('./container-config.js').ContainerConfig,
404	  provider: string,
405	  providerContribution: ProviderContainerContribution,
406	  agentIdentifier?: string,
407	): Promise<string[]> {
408	  const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL];
409
410	  // Environment — only vars read by code we don't own.
411	  // Everything NanoClaw-specific is in container.json (read by runner at startup).
412	  args.push('-e', `TZ=${TIMEZONE}`);
413
414	  // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY).
415	  if (providerContribution.env) {
416	    for (const [key, value] of Object.entries(providerContribution.env)) {
417	      args.push('-e', `${key}=${value}`);
418	    }
419	  }
420
421	  // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
422	  // are routed through the agent vault for credential injection. Treated as
423	  // a transient hard failure: if we can't wire the gateway, we don't spawn.
424	  // The caller (router or host-sweep) catches the throw, leaves the inbound
425	  // message pending, and the next sweep tick retries.
426	  if (agentIdentifier) {
427	    await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
428	  }
429	  const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
430	  if (!onecliApplied) {
431	    throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
432	  }
433	  log.info('OneCLI gateway applied', { containerName });
434
435	  // Host gateway
436	  args.push(...hostGatewayArgs());
437
438	  // User mapping
439	  const hostUid = process.getuid?.();
440	  const hostGid = process.getgid?.();
441	  if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
442	    args.push('--user', `${hostUid}:${hostGid}`);
443	    args.push('-e', 'HOME=/home/node');
444	  }
445
446	  // Volume mounts
447	  for (const mount of mounts) {
448	    if (mount.readonly) {
449	      args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
450	    } else {
451	      args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
452	    }
453	  }
454
455	  // Override entrypoint: run v2 entry point directly via Bun (no tsc, no stdin).
456	  args.push('--entrypoint', 'bash');
457
458	  // Use per-agent-group image if one has been built, otherwise base image
459	  const imageTag = containerConfig.imageTag || CONTAINER_IMAGE;
460	  args.push(imageTag);
461
462	  args.push('-c', 'exec bun run /app/src/index.ts');
463
464	  return args;
465	}
466
467	/** Build a per-agent-group Docker image with custom packages. */
468	export async function buildAgentGroupImage(agentGroupId: string): Promise<void> {
469	  const agentGroup = getAgentGroup(agentGroupId);
470	  if (!agentGroup) throw new Error('Agent group not found');
471
472	  const configRow = getContainerConfig(agentGroup.id);
473	  if (!configRow) throw new Error('Container config not found');
474	  const aptPackages = JSON.parse(configRow.packages_apt) as string[];
475	  const npmPackages = JSON.parse(configRow.packages_npm) as string[];
476	  if (aptPackages.length === 0 && npmPackages.length === 0) {
477	    throw new Error('No packages to install. Use install_packages first.');
478	  }
479
480	  let dockerfile = `FROM ${CONTAINER_IMAGE}\nUSER root\n`;
481	  if (aptPackages.length > 0) {
482	    dockerfile += `RUN apt-get update && apt-get install -y ${aptPackages.join(' ')} && rm -rf /var/lib/apt/lists/*\n`;
483	  }
484	  if (npmPackages.length > 0) {
485	    // pnpm skips build scripts unless packages are allowlisted. Append each
486	    // to /root/.npmrc (base image sets it up for agent-browser) so packages
487	    // with postinstall — e.g. playwright, puppeteer, native addons — don't
488	    // install silently broken.
489	    const allowlist = npmPackages.map((p) => `echo 'only-built-dependencies[]=${p}' >> /root/.npmrc`).join(' && ');
490	    dockerfile += `RUN ${allowlist} && pnpm install -g ${npmPackages.join(' ')}\n`;
491	  }
492	  dockerfile += 'USER node\n';
493
494	  const imageTag = `${CONTAINER_IMAGE_BASE}:${agentGroupId}`;
495
496	  log.info('Building per-agent-group image', { agentGroupId, imageTag, apt: aptPackages, npm: npmPackages });
497
498	  // Write Dockerfile to temp file and build
499	  const tmpDockerfile = path.join(DATA_DIR, `Dockerfile.${agentGroupId}`);


Now let me look at the mount security validation:


find /tmp/nanoclaw-main -path "*mount-security*" -type f


1	/**
2	 * Mount Security Module for NanoClaw
3	 *
4	 * Validates additional mounts against an allowlist stored OUTSIDE the project root.
5	 * This prevents container agents from modifying security configuration.
6	 *
7	 * Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
8	 */
9	import fs from 'fs';
10	import os from 'os';
11	import path from 'path';
12	import { MOUNT_ALLOWLIST_PATH } from '../../config.js';
13	import { log } from '../../log.js';
14
15	export interface AdditionalMount {
16	  hostPath: string;
17	  containerPath?: string;
18	  readonly?: boolean;
19	}
20
21	export interface MountAllowlist {
22	  allowedRoots: AllowedRoot[];
23	  blockedPatterns: string[];
24	}
25
26	export interface AllowedRoot {
27	  path: string;
28	  allowReadWrite: boolean;
29	  description?: string;
30	}
31
32	// Cache the allowlist in memory - only reloads on process restart
33	let cachedAllowlist: MountAllowlist | null = null;
34	let allowlistLoadError: string | null = null;
35
36	/**
37	 * Default blocked patterns - paths that should never be mounted
38	 */
39	const DEFAULT_BLOCKED_PATTERNS = [
40	  '.ssh',
41	  '.gnupg',
42	  '.gpg',
43	  '.aws',
44	  '.azure',
45	  '.gcloud',
46	  '.kube',
47	  '.docker',
48	  'credentials',
49	  '.env',
50	  '.netrc',
51	  '.npmrc',
52	  '.pypirc',
53	  'id_rsa',
54	  'id_ed25519',
55	  'private_key',
56	  '.secret',
57	];
58
59	/**
60	 * Load the mount allowlist from the external config location.
61	 * Returns null if the file doesn't exist or is invalid.
62	 * Result is cached in memory for the lifetime of the process.
63	 */
64	export function loadMountAllowlist(): MountAllowlist | null {
65	  if (cachedAllowlist !== null) {
66	    return cachedAllowlist;
67	  }
68
69	  if (allowlistLoadError !== null) {
70	    // Already tried and failed, don't spam logs
71	    return null;
72	  }
73
74	  try {
75	    if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
76	      // Do NOT cache this as an error — file may be created later without restart.
77	      // Only parse/structural errors are permanently cached.
78	      log.warn(
79	        'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
80	        { path: MOUNT_ALLOWLIST_PATH },
81	      );
82	      return null;
83	    }
84
85	    const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
86	    const allowlist = JSON.parse(content) as MountAllowlist;
87
88	    // Validate structure
89	    if (!Array.isArray(allowlist.allowedRoots)) {
90	      throw new Error('allowedRoots must be an array');
91	    }
92
93	    if (!Array.isArray(allowlist.blockedPatterns)) {
94	      throw new Error('blockedPatterns must be an array');
95	    }
96
97	    // Merge with default blocked patterns
98	    const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
99	    allowlist.blockedPatterns = mergedBlockedPatterns;
100
101	    cachedAllowlist = allowlist;
102	    log.info('Mount allowlist loaded successfully', {
103	      path: MOUNT_ALLOWLIST_PATH,
104	      allowedRoots: allowlist.allowedRoots.length,
105	      blockedPatterns: allowlist.blockedPatterns.length,
106	    });
107
108	    return cachedAllowlist;
109	  } catch (err) {
110	    allowlistLoadError = err instanceof Error ? err.message : String(err);
111	    log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
112	      path: MOUNT_ALLOWLIST_PATH,
113	      error: allowlistLoadError,
114	    });
115	    return null;
116	  }
117	}
118
119	/**
120	 * Expand ~ to home directory and resolve to absolute path
121	 */
122	function expandPath(p: string): string {
123	  const homeDir = process.env.HOME || os.homedir();
124	  if (p.startsWith('~/')) {
125	    return path.join(homeDir, p.slice(2));
126	  }
127	  if (p === '~') {
128	    return homeDir;
129	  }
130	  return path.resolve(p);
131	}
132
133	/**
134	 * Get the real path, resolving symlinks.
135	 * Returns null if the path doesn't exist.
136	 */
137	function getRealPath(p: string): string | null {
138	  try {
139	    return fs.realpathSync(p);
140	  } catch {
141	    return null;
142	  }
143	}
144
145	/**
146	 * Check if a path matches any blocked pattern
147	 */
148	function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null {
149	  const pathParts = realPath.split(path.sep);
150
151	  for (const pattern of blockedPatterns) {
152	    // Check if any path component matches the pattern
153	    for (const part of pathParts) {
154	      if (part === pattern || part.includes(pattern)) {
155	        return pattern;
156	      }
157	    }
158
159	    // Also check if the full path contains the pattern
160	    if (realPath.includes(pattern)) {
161	      return pattern;
162	    }
163	  }
164
165	  return null;
166	}
167
168	/**
169	 * Check if a real path is under an allowed root
170	 */
171	function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null {
172	  for (const root of allowedRoots) {
173	    const expandedRoot = expandPath(root.path);
174	    const realRoot = getRealPath(expandedRoot);
175
176	    if (realRoot === null) {
177	      // Allowed root doesn't exist, skip it
178	      continue;
179	    }
180
181	    // Check if realPath is under realRoot
182	    const relative = path.relative(realRoot, realPath);
183	    if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
184	      return root;
185	    }
186	  }
187
188	  return null;
189	}
190
191	/**
192	 * Validate the container path to prevent escaping /workspace/extra/
193	 */
194	function isValidContainerPath(containerPath: string): boolean {
195	  // Must not contain .. to prevent path traversal
196	  if (containerPath.includes('..')) {
197	    return false;
198	  }
199
200	  // Must not be absolute (it will be prefixed with /workspace/extra/)
201	  if (containerPath.startsWith('/')) {
202	    return false;
203	  }
204
205	  // Must not be empty
206	  if (!containerPath || containerPath.trim() === '') {
207	    return false;
208	  }
209
210	  // Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw")
211	  if (containerPath.includes(':')) {
212	    return false;
213	  }
214
215	  return true;
216	}
217
218	export interface MountValidationResult {
219	  allowed: boolean;
220	  reason: string;
221	  realHostPath?: string;
222	  resolvedContainerPath?: string;
223	  effectiveReadonly?: boolean;
224	}
225
226	/**
227	 * Validate a single additional mount against the allowlist.
228	 * Returns validation result with reason.
229	 */
230	export function validateMount(mount: AdditionalMount): MountValidationResult {
231	  const allowlist = loadMountAllowlist();
232
233	  // If no allowlist, block all additional mounts
234	  if (allowlist === null) {
235	    return {
236	      allowed: false,
237	      reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
238	    };
239	  }
240
241	  // Derive containerPath from hostPath basename if not specified
242	  const containerPath = mount.containerPath || path.basename(mount.hostPath);
243
244	  // Validate container path (cheap check)
245	  if (!isValidContainerPath(containerPath)) {
246	    return {
247	      allowed: false,
248	      reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`,
249	    };
250	  }
251
252	  // Expand and resolve the host path
253	  const expandedPath = expandPath(mount.hostPath);
254	  const realPath = getRealPath(expandedPath);
255
256	  if (realPath === null) {
257	    return {
258	      allowed: false,
259	      reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
260	    };
261	  }
262
263	  // Check against blocked patterns
264	  const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns);
265	  if (blockedMatch !== null) {
266	    return {
267	      allowed: false,
268	      reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
269	    };
270	  }
271
272	  // Check if under an allowed root
273	  const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots);
274	  if (allowedRoot === null) {
275	    return {
276	      allowed: false,
277	      reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
278	        .map((r) => expandPath(r.path))
279	        .join(', ')}`,
280	    };
281	  }
282
283	  // Determine effective readonly status.
284	  // RW is only granted if the mount explicitly requests it AND the allowed
285	  // root permits it. Otherwise it's forced read-only.
286	  const requestedReadWrite = mount.readonly === false;
287	  let effectiveReadonly = true;
288
289	  if (requestedReadWrite) {
290	    if (!allowedRoot.allowReadWrite) {
291	      log.info('Mount forced to read-only - root does not allow read-write', {
292	        mount: mount.hostPath,
293	        root: allowedRoot.path,
294	      });
295	    } else {
296	      effectiveReadonly = false;
297	    }
298	  }
299
300	  return {
301	    allowed: true,
302	    reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
303	    realHostPath: realPath,
304	    resolvedContainerPath: containerPath,
305	    effectiveReadonly,
306	  };
307	}
308
309	/**
310	 * Validate all additional mounts for a group.
311	 * Returns array of validated mounts (only those that passed validation).
312	 * Logs warnings for rejected mounts.
313	 */
314	export function validateAdditionalMounts(
315	  mounts: AdditionalMount[],
316	  groupName: string,
317	): Array<{
318	  hostPath: string;
319	  containerPath: string;
320	  readonly: boolean;
321	}> {
322	  const validatedMounts: Array<{
323	    hostPath: string;
324	    containerPath: string;
325	    readonly: boolean;
326	  }> = [];
327
328	  for (const mount of mounts) {
329	    const result = validateMount(mount);
330
331	    if (result.allowed) {
332	      validatedMounts.push({
333	        hostPath: result.realHostPath!,
334	        containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
335	        readonly: result.effectiveReadonly!,
336	      });
337
338	      log.debug('Mount validated successfully', {
339	        group: groupName,
340	        hostPath: result.realHostPath,
341	        containerPath: result.resolvedContainerPath,
342	        readonly: result.effectiveReadonly,
343	        reason: result.reason,
344	      });
345	    } else {
346	      log.warn('Additional mount REJECTED', {
347	        group: groupName,
348	        requestedPath: mount.hostPath,
349	        containerPath: mount.containerPath,
350	        reason: result.reason,
351	      });
352	    }
353	  }
354
355	  return validatedMounts;
356	}
357
358	/**
359	 * Generate a template allowlist file for users to customize
360	 */
361	export function generateAllowlistTemplate(): string {
362	  const template: MountAllowlist = {
363	    allowedRoots: [
364	      {
365	        path: '~/projects',
366	        allowReadWrite: true,
367	        description: 'Development projects',
368	      },
369	      {
370	        path: '~/repos',
371	        allowReadWrite: true,
372	        description: 'Git repositories',
373	      },
374	      {
375	        path: '~/Documents/work',
376	        allowReadWrite: false,
377	        description: 'Work documents (read-only)',
378	      },
379	    ],
380	    blockedPatterns: [
381	      // Additional patterns beyond defaults
382	      'password',
383	      'secret',
384	      'token',
385	    ],
386	  };
387
388	  return JSON.stringify(template, null, 2);
389	}
390


Now let me check the config file and types:


grep -n "MOUNT_ALLOWLIST_PATH" /tmp/nanoclaw-main/src/config.ts


20:export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json');


1	// ── Central DB entities ──
2
3	export interface AgentGroup {
4	  id: string;
5	  name: string;
6	  folder: string;
7	  /** @deprecated Use container_configs.provider instead. */
8	  agent_provider: string | null;
9	  created_at: string;
10	}
11
12	/** Per-agent-group container runtime config. Source of truth in the DB;
13	 *  materialized to `groups/<folder>/container.json` at spawn time. */
14	export interface ContainerConfigRow {
15	  agent_group_id: string;
16	  provider: string | null;
17	  model: string | null;
18	  effort: string | null;
19	  image_tag: string | null;
20	  assistant_name: string | null;
21	  max_messages_per_prompt: number | null;
22	  skills: string; // JSON: '"all"' | '["skill1","skill2"]'
23	  mcp_servers: string; // JSON: Record<string, McpServerConfig>
24	  packages_apt: string; // JSON: string[]
25	  packages_npm: string; // JSON: string[]
26	  additional_mounts: string; // JSON: AdditionalMountConfig[]
27	  cli_scope: string; // 'disabled' | 'group' | 'global'
28	  updated_at: string;
29	}
30
31	export type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public';
32
33	export interface MessagingGroup {
34	  id: string;
35	  channel_type: string;
36	  platform_id: string;
37	  name: string | null;
38	  is_group: number; // 0 | 1
39	  unknown_sender_policy: UnknownSenderPolicy;
40	  /**
41	   * When set, the owner explicitly denied registering this channel — the
42	   * router drops silently and does not re-escalate. Cleared by any explicit
43	   * wiring mutation (admin command). See migration 012.
44	   *
45	   * Optional on the TS type so pre-migration-012 callers that build
46	   * MessagingGroup objects in code (fixtures, etc.) don't need to update;
47	   * the column itself defaults to NULL in SQLite.
48	   */
49	  denied_at?: string | null;
50	  created_at: string;
51	}
52
53	// ── Identity & privilege ──
54
55	/**
56	 * User = a messaging-platform identifier. Namespaced so distinct channels
57	 * with numeric IDs don't collide: "phone:+1555...", "tg:123", "discord:456",
58	 * "email:a@x.com". A single human with a phone AND a telegram handle has
59	 * two separate users — no cross-channel linking (yet).
60	 */
61	export interface User {
62	  id: string;
63	  kind: string; // 'phone' | 'email' | 'discord' | 'telegram' | 'matrix' | ...
64	  display_name: string | null;
65	  created_at: string;
66	}
67
68	export type UserRoleKind = 'owner' | 'admin';
69
70	/**
71	 * Role grant. Owner is always global. Admin is either global
72	 * (agent_group_id = null) or scoped to a specific agent group.
73	 * Admin @ A implicitly makes the user a member of A — we do not require
74	 * a separate agent_group_members row for admins.
75	 */
76	export interface UserRole {
77	  user_id: string;
78	  role: UserRoleKind;
79	  agent_group_id: string | null;
80	  granted_by: string | null;
81	  granted_at: string;
82	}
83
84	/** "Known" membership in an agent group — required for unprivileged users. */
85	export interface AgentGroupMember {
86	  user_id: string;
87	  agent_group_id: string;
88	  added_by: string | null;
89	  added_at: string;
90	}
91
92	/** Cached DM channel for a user on a specific channel_type. */
93	export interface UserDm {
94	  user_id: string;
95	  channel_type: string;
96	  messaging_group_id: string;
97	  resolved_at: string;
98	}
99
100	export type EngageMode = 'pattern' | 'mention' | 'mention-sticky';
101	export type SenderScope = 'all' | 'known';
102	export type IgnoredMessagePolicy = 'drop' | 'accumulate';
103
104	export interface MessagingGroupAgent {
105	  id: string;
106	  messaging_group_id: string;
107	  agent_group_id: string;
108	  engage_mode: EngageMode;
109	  /**
110	   * Regex source string used when engage_mode='pattern'. `'.'` is the sentinel
111	   * for "match every message" (the "always" flavor). Ignored for 'mention' /
112	   * 'mention-sticky' modes.
113	   */
114	  engage_pattern: string | null;
115	  sender_scope: SenderScope;
116	  ignored_message_policy: IgnoredMessagePolicy;
117	  session_mode: 'shared' | 'per-thread' | 'agent-shared';
118	  priority: number;
119	  created_at: string;
120	}
121
122	export interface Session {
123	  id: string;
124	  agent_group_id: string;
125	  messaging_group_id: string | null;
126	  thread_id: string | null;
127	  agent_provider: string | null;
128	  status: 'active' | 'closed';
129	  container_status: 'running' | 'idle' | 'stopped';
130	  last_active: string | null;
131	  created_at: string;
132	}
133
134	// ── Session DB entities ──
135
136	export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
137	export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
138
139	export interface MessageIn {
140	  id: string;
141	  kind: MessageInKind;
142	  timestamp: string;
143	  status: MessageInStatus;
144	  status_changed: string | null;
145	  process_after: string | null;
146	  recurrence: string | null;
147	  tries: number;
148	  platform_id: string | null;
149	  channel_type: string | null;
150	  thread_id: string | null;


Now let me check for any CLI commands related to adding mounts or admin UI:


grep -r "config.*mounts\|add.*mount\|mount.*add" /tmp/nanoclaw-main/src/cli --include="*.ts" -i


/tmp/nanoclaw-main/src/cli/resources/groups.test.ts:          skills, mcp_servers, packages_apt, packages_npm, additional_mounts, cli_scope, updated_at)
/tmp/nanoclaw-main/src/cli/resources/groups.ts:    additional_mounts: JSON.parse(row.additional_mounts),


Let me check if there's a dedicated CLI command for managing mounts by searching more broadly:


grep -r "additional.mounts\|additional_mounts" /tmp/nanoclaw-main/src/cli --include="*.ts" -B 3 -A 3


/tmp/nanoclaw-main/src/cli/resources/groups.test.ts-    db.prepare(
/tmp/nanoclaw-main/src/cli/resources/groups.test.ts-      `INSERT INTO container_configs
/tmp/nanoclaw-main/src/cli/resources/groups.test.ts-         (agent_group_id, provider, model, effort, image_tag, assistant_name, max_messages_per_prompt,
/tmp/nanoclaw-main/src/cli/resources/groups.test.ts:          skills, mcp_servers, packages_apt, packages_npm, additional_mounts, cli_scope, updated_at)
/tmp/nanoclaw-main/src/cli/resources/groups.test.ts-       VALUES (?, NULL, NULL, NULL, NULL, NULL, NULL, '"all"', '{}', '[]', '[]', '[]', 'group', ?)`,
/tmp/nanoclaw-main/src/cli/resources/groups.test.ts-    ).run(GID, now());
/tmp/nanoclaw-main/src/cli/resources/groups.test.ts-
--
/tmp/nanoclaw-main/src/cli/resources/groups.ts-    mcp_servers: JSON.parse(row.mcp_servers),
/tmp/nanoclaw-main/src/cli/resources/groups.ts-    packages_apt: JSON.parse(row.packages_apt),
/tmp/nanoclaw-main/src/cli/resources/groups.ts-    packages_npm: JSON.parse(row.packages_npm),
/tmp/nanoclaw-main/src/cli/resources/groups.ts:    additional_mounts: JSON.parse(row.additional_mounts),
/tmp/nanoclaw-main/src/cli/resources/groups.ts-    cli_scope: row.cli_scope,
/tmp/nanoclaw-main/src/cli/resources/groups.ts-    updated_at: row.updated_at,
/tmp/nanoclaw-main/src/cli/resources/groups.ts-  };


Let me check the docs to see if there's documentation about managing mounts:


find /tmp/nanoclaw-main/docs -name "*.md" -type f


/tmp/nanoclaw-main/docs/README.md
/tmp/nanoclaw-main/docs/isolation-model.md
/tmp/nanoclaw-main/docs/architecture.md
/tmp/nanoclaw-main/docs/SECURITY.md
/tmp/nanoclaw-main/docs/agent-runner-details.md
/tmp/nanoclaw-main/docs/setup-wiring.md
/tmp/nanoclaw-main/docs/APPLE-CONTAINER-NETWORKING.md
/tmp/nanoclaw-main/docs/setup-flow.md
/tmp/nanoclaw-main/docs/db.md
/tmp/nanoclaw-main/docs/skills-as-branches.md
/tmp/nanoclaw-main/docs/SDK_DEEP_DIVE.md
/tmp/nanoclaw-main/docs/api-details.md
/tmp/nanoclaw-main/docs/migration-dev.md
/tmp/nanoclaw-main/docs/repo-map.md
/tmp/nanoclaw-main/docs/build-and-runtime.md
/tmp/nanoclaw-main/docs/SPEC.md
/tmp/nanoclaw-main/docs/v1-to-v2-changes.md
/tmp/nanoclaw-main/docs/instagram-ingestion.md
/tmp/nanoclaw-main/docs/REQUIREMENTS.md
/tmp/nanoclaw-main/docs/db-central.md
/tmp/nanoclaw-main/docs/BRANCH-FORK-MAINTENANCE.md
/tmp/nanoclaw-main/docs/backlog.md
/tmp/nanoclaw-main/docs/db-session.md
/tmp/nanoclaw-main/docs/architecture-diagram.md
/tmp/nanoclaw-main/docs/docker-sandboxes.md
/tmp/nanoclaw-main/docs/ollama.md


grep -l "mount\|additional" /tmp/nanoclaw-main/docs/*.md | head -5


/tmp/nanoclaw-main/docs/REQUIREMENTS.md
/tmp/nanoclaw-main/docs/SDK_DEEP_DIVE.md
/tmp/nanoclaw-main/docs/SECURITY.md
/tmp/nanoclaw-main/docs/SPEC.md
/tmp/nanoclaw-main/docs/agent-runner-details.md


grep -A 10 -B 2 "additional.mount\|mount-allowlist" /tmp/nanoclaw-main/docs/SECURITY.md


### 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

**Default Blocked Patterns:**
```
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
private_key, .secret
```


Now let me check for any seed scripts or database initialization:


find /tmp/nanoclaw-main -name "*seed*" -o -name "*init*" | grep -E "\.(ts|js|json)$"


/tmp/nanoclaw-main/src/db/migrations/001-initial.ts
/tmp/nanoclaw-main/src/group-init.ts
/tmp/nanoclaw-main/scripts/init-first-agent.ts
/tmp/nanoclaw-main/scripts/seed-discord.ts
/tmp/nanoclaw-main/scripts/init-cli-agent.ts


1	/**
2	 * Init the first (or Nth) NanoClaw v2 agent for a DM channel.
3	 *
4	 * Wires a real DM channel (discord, telegram, etc.) to a new agent group,
5	 * then hands a welcome message to the running service via the CLI socket
6	 * (admin transport). The service routes that message into the DM session,
7	 * which wakes the container synchronously — the agent processes the welcome
8	 * and DMs the operator through the normal delivery path.
9	 *
10	 * CLI channel wiring is handled separately by `scripts/init-cli-agent.ts`.
11	 *
12	 * Creates/reuses: user, owner grant (if none), agent group + filesystem,
13	 * messaging group(s), wiring.
14	 *
15	 * Runs alongside the service (WAL-mode sqlite + CLI socket IPC) — does NOT
16	 * initialize channel adapters, so there's no Gateway conflict. Requires
17	 * the service to be running: the welcome hand-off goes over the CLI socket
18	 * and fails loudly if the service isn't up.
19	 *
20	 * Usage:
21	 *   pnpm exec tsx scripts/init-first-agent.ts \
22	 *     --channel discord \
23	 *     --user-id discord:1470183333427675709 \
24	 *     --platform-id discord:@me:1491573333382523708 \
25	 *     --display-name "Gavriel" \
26	 *     [--agent-name "Andy"] \
27	 *     [--welcome "System instruction: ..."] \
28	 *     [--role owner|admin|member]    # default: owner
29	 *
30	 * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
31	 * is typically the same as the handle in --user-id, with the channel prefix.
32	 */
33	import net from 'net';
34	import path from 'path';
35
36	import { DATA_DIR } from '../src/config.js';
37	import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
38	import { initDb } from '../src/db/connection.js';
39	import {
40	  createMessagingGroup,
41	  createMessagingGroupAgent,
42	  getMessagingGroupAgentByPair,
43	  getMessagingGroupByPlatform,
44	} from '../src/db/messaging-groups.js';
45	import { runMigrations } from '../src/db/migrations/index.js';
46	import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js';
47	import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
48	import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
49	import { upsertUser } from '../src/modules/permissions/db/users.js';
50	import { updateContainerConfigScalars } from '../src/db/container-configs.js';
51	import { initGroupFilesystem } from '../src/group-init.js';
52	import { namespacedPlatformId } from '../src/platform-id.js';
53	import type { AgentGroup, MessagingGroup } from '../src/types.js';
54
55	type Role = 'owner' | 'admin' | 'member';
56
57	interface Args {
58	  channel: string;
59	  userId: string;
60	  platformId: string;
61	  displayName: string;
62	  agentName: string;
63	  welcome: string;
64	  role: Role;
65	}
66
67	const DEFAULT_WELCOME =
68	  'System instruction: run /welcome to introduce yourself to the user on this new channel.';
69
70	const DEFAULT_ROLE: Role = 'owner';
71
72	function parseArgs(argv: string[]): Args {
73	  const out: Partial<Args> = {};
74	  for (let i = 0; i < argv.length; i++) {
75	    const key = argv[i];
76	    const val = argv[i + 1];
77	    switch (key) {
78	      case '--channel':
79	        out.channel = (val ?? '').toLowerCase();
80	        i++;
81	        break;
82	      case '--user-id':
83	        out.userId = val;
84	        i++;
85	        break;
86	      case '--platform-id':
87	        out.platformId = val;
88	        i++;
89	        break;
90	      case '--display-name':
91	        out.displayName = val;
92	        i++;
93	        break;
94	      case '--agent-name':
95	        out.agentName = val;
96	        i++;
97	        break;
98	      case '--welcome':
99	        out.welcome = val;
100	        i++;
101	        break;
102	      case '--role': {
103	        const raw = (val ?? '').toLowerCase();
104	        if (raw !== 'owner' && raw !== 'admin' && raw !== 'member') {
105	          console.error(
106	            `Invalid --role: ${raw} (expected 'owner', 'admin', or 'member')`,
107	          );
108	          process.exit(2);
109	        }
110	        out.role = raw;
111	        i++;
112	        break;
113	      }
114	    }
115	  }
116
117	  const required: (keyof Args)[] = ['channel', 'userId', 'platformId', 'displayName'];
118	  const missing = required.filter((k) => !out[k]);
119	  if (missing.length) {
120	    console.error(
121	      `Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`,
122	    );
123	    console.error('See scripts/init-first-agent.ts header for usage.');
124	    process.exit(2);
125	  }
126
127	  return {
128	    channel: out.channel!,
129	    userId: out.userId!,
130	    platformId: out.platformId!,
131	    displayName: out.displayName!,
132	    agentName: out.agentName?.trim() || out.displayName!,
133	    welcome: out.welcome?.trim() || DEFAULT_WELCOME,
134	    role: out.role ?? DEFAULT_ROLE,
135	  };
136	}
137
138	function namespacedUserId(channel: string, raw: string): string {
139	  return raw.includes(':') ? raw : `${channel}:${raw}`;
140	}
141
142	function generateId(prefix: string): string {
143	  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
144	}
145
146	function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void {
147	  const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
148	  if (existing) {
149	    console.log(`Wiring already exists: ${existing.id} (${label})`);
150	    return;
151	  }
152	  createMessagingGroupAgent({
153	    id: generateId('mga'),
154	    messaging_group_id: mg.id,
155	    agent_group_id: ag.id,
156	    // DM / CLI (is_group=0) default to "respond to everything" via a '.' regex.
157	    // Group chats default to mention-only; admins can upgrade to mention-sticky
158	    // via /manage-channels once the agent is in use.
159	    engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
160	    engage_pattern: mg.is_group === 0 ? '.' : null,
161	    sender_scope: 'all',
162	    ignored_message_policy: 'drop',
163	    session_mode: 'shared',
164	    priority: 0,
165	    created_at: now,
166	  });
167	  console.log(`Wired ${label}: ${mg.id} -> ${ag.id}`);
168	}
169
170	async function main(): Promise<void> {
171	  const args = parseArgs(process.argv.slice(2));
172
173	  const db = initDb(path.join(DATA_DIR, 'v2.db'));
174	  runMigrations(db); // idempotent
175
176	  const now = new Date().toISOString();
177
178	  // 1. User + (conditional) owner grant.
179	  const userId = namespacedUserId(args.channel, args.userId);
180	  upsertUser({
181	    id: userId,
182	    kind: args.channel,
183	    display_name: args.displayName,
184	    created_at: now,
185	  });
186
187	  // Owner grant is deferred until after the agent group is resolved, since
188	  // an admin grant is scoped to that group. See step 2b.
189
190	  // 2. Agent group + filesystem.
191	  const folder = `dm-with-${normalizeName(args.displayName)}`;
192	  let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
193	  if (!ag) {
194	    const agId = generateId('ag');
195	    createAgentGroup({
196	      id: agId,
197	      name: args.agentName,
198	      folder,
199	      agent_provider: null,
200	      created_at: now,
201	    });
202	    ag = getAgentGroupByFolder(folder)!;
203	    console.log(`Created agent group: ${ag.id} (${folder})`);
204	  } else {
205	    console.log(`Reusing agent group: ${ag.id} (${folder})`);
206	  }
207	  initGroupFilesystem(ag, {
208	    instructions:
209	      `# ${args.agentName}\n\n` +
210	      `You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
211	      'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.',
212	  });
213
214	  // 2b. Assign the user a role for this agent group. The caller picks via
215	  // --role; the channel drivers default to 'owner' for the self-host case.
216	  //  - owner:  global owner (agent_group_id=null). Cross-channel access.
217	  //  - admin:  scoped admin for this agent group only.
218	  //  - member: no role grant, just the membership row below.
219	  // grantRole inserts a new row per call — idempotence check against
220	  // getUserRoles prevents duplicates on re-runs.
221	  const existingRoles = getUserRoles(userId);
222	  if (args.role === 'owner') {
223	    const alreadyOwner = existingRoles.some(
224	      (r) => r.role === 'owner' && r.agent_group_id === null,
225	    );
226	    if (!alreadyOwner) {
227	      grantRole({
228	        user_id: userId,
229	        role: 'owner',
230	        agent_group_id: null,
231	        granted_by: null,
232	        granted_at: now,
233	      });
234	    }
235	    // Owner's agent group gets global CLI access
236	    updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
237	  } else if (args.role === 'admin') {
238	    const alreadyAdmin = existingRoles.some(
239	      (r) => r.role === 'admin' && r.agent_group_id === ag.id,
240	    );
241	    if (!alreadyAdmin) {
242	      grantRole({
243	        user_id: userId,
244	        role: 'admin',
245	        agent_group_id: ag.id,
246	        granted_by: null,
247	        granted_at: now,
248	      });
249	    }
250	  }
251
252	  // Always add a membership row so the access gate has a straightforward
253	  // yes/no even for users without a role grant. INSERT OR IGNORE, so this
254	  // is a no-op when the row already exists (e.g. re-runs, owners whose
255	  // access already passes via role).
256	  addMember({
257	    user_id: userId,
258	    agent_group_id: ag.id,
259	    added_by: null,
260	    added_at: now,
261	  });
262
263	  // 3. DM messaging group.
264	  const platformId = namespacedPlatformId(args.channel, args.platformId);
265	  let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
266	  if (!dmMg) {
267	    const mgId = generateId('mg');
268	    createMessagingGroup({
269	      id: mgId,
270	      channel_type: args.channel,
271	      platform_id: platformId,
272	      name: args.displayName,
273	      is_group: 0,
274	      unknown_sender_policy: 'strict',
275	      created_at: now,
276	    });
277	    dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
278	    console.log(`Created messaging group: ${dmMg.id} (${platformId})`);
279	  } else {
280	    console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`);
281	  }
282
283	  // 4. Wire DM messaging group to the agent.
284	  wireIfMissing(dmMg, ag, now, 'dm');
285
286	  // 5. Welcome delivery over the CLI socket. Router picks up the line,
287	  // writes the message into the DM session's inbound.db, and wakes the
288	  // container synchronously — no sweep wait. The paired user's identity is
289	  // passed so the sender resolver sees the real owner, not cli:local.
290	  await sendWelcomeViaCliSocket(dmMg, args.welcome, {
291	    senderId: userId,
292	    sender: args.displayName,
293	  });
294
295	  const roleLabel =
296	    args.role === 'owner'
297	      ? 'owner (global)'
298	      : args.role === 'admin'
299	        ? `admin (scoped to ${ag.id})`
300	        : 'member';
301
302	  console.log('');
303	  console.log('Init complete.');
304	  console.log(`  user:    ${userId}`);
305	  console.log(`  role:    ${roleLabel}`);
306	  console.log(`  agent:   ${ag.name} [${ag.id}] @ groups/${folder}`);
307	  console.log(`  channel: ${args.channel} ${dmMg.platform_id}`);
308	  console.log('');
309	  console.log('Welcome DM queued — the agent will greet you shortly.');
310	}
311
312	/**
313	 * Hand the welcome to the running service via its CLI Unix socket. The
314	 * service's CLI adapter receives `{text, to}`, builds an InboundEvent
315	 * targeting the DM messaging group, and calls routeInbound(). Router writes
316	 * the message into inbound.db and wakes the container synchronously.
317	 *
318	 * Throws if the socket isn't reachable — this script requires the service
319	 * to be running.
320	 */
321	async function sendWelcomeViaCliSocket(
322	  dmMg: MessagingGroup,
323	  welcome: string,
324	  identity: { senderId: string; sender: string },
325	): Promise<void> {
326	  const sockPath = path.join(DATA_DIR, 'cli.sock');
327
328	  await new Promise<void>((resolve, reject) => {
329	    const socket = net.connect(sockPath);
330	    let settled = false;
331
332	    const settle = (err: Error | null) => {
333	      if (settled) return;
334	      settled = true;
335	      try {
336	        socket.end();
337	      } catch {
338	        /* noop */
339	      }
340	      if (err) reject(err);
341	      else resolve();
342	    };
343
344	    socket.once('error', (err) =>
345	      settle(
346	        new Error(
347	          `CLI socket at ${sockPath} not reachable: ${err.message}. Is the NanoClaw service running?`,
348	        ),
349	      ),
350	    );
351	    socket.once('connect', () => {
352	      const payload =
353	        JSON.stringify({
354	          text: welcome,
355	          senderId: identity.senderId,
356	          sender: identity.sender,
357	          to: {
358	            channelType: dmMg.channel_type,
359	            platformId: dmMg.platform_id,
360	            threadId: dmMg.platform_id,
361	          },
362	        }) + '\n';
363	      socket.write(payload, (err) => {
364	        if (err) {
365	          settle(err);
366	          return;
367	        }
368	        // Brief flush delay so the router picks up the line before we close.
369	        // Router handles it synchronously once read, so 50ms is plenty.
370	        setTimeout(() => settle(null), 50);
371	      });
372	    });
373	  });
374	}
375
376	main().catch((err) => {
377	  console.error(err instanceof Error ? err.message : err);
378	  process.exit(1);
379	});
380


Let me check if there's a CLI command to manage mounts by searching all the group CLI operations more thoroughly:


grep -n "config.*\|customOperations" /tmp/nanoclaw-main/src/cli/resources/groups.ts | head -40


1:import type { McpServerConfig } from '../../container-config.js';
11:} from '../../db/container-configs.js';
40:    'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.',
63:  // provided as `customOperations.delete` below.
65:  customOperations: {
102:            container_configs: 0,
139:          // migration-014 has ON DELETE CASCADE on container_configs.agent_group_id;
141:          counts.container_configs = db
142:            .prepare('DELETE FROM container_configs WHERE agent_group_id = ?')
201:    'config get': {
203:      description: 'Show the container config for a group. Use --id <group-id>.',
208:        if (!row) throw new Error(`No container config for group: ${id}`);
212:    'config update': {
215:        'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
221:        if (!row) throw new Error(`No container config for group: ${id}`);
256:    'config add-mcp-server': {
270:        if (!row) throw new Error(`No container config for group: ${id}`);
283:    'config remove-mcp-server': {
294:        if (!row) throw new Error(`No container config for group: ${id}`);
304:    'config add-package': {
313:        if (!row) throw new Error(`No container config for group: ${id}`);
340:    'config remove-package': {
349:        if (!row) throw new Error(`No container config for group: ${id}`);


Let me look at the entire customOperations section to see if there's a mount command:


360	        if (npm) {
361	          const existing = JSON.parse(row.packages_npm) as string[];
362	          const filtered = existing.filter((p) => p !== npm);
363	          updateContainerConfigJson(id, 'packages_npm', filtered);
364	        }
365
366	        return {
367	          removed: { apt: apt || null, npm: npm || null },
368	          note: 'Image rebuild required for package changes to take effect.',
369	        };
370	      },
371	    },
372	  },
373	});
374


Now let me look at the database schema and documentation about how mounts are actually stored: