Merge pull request #1914 from qwibitai/v2-with-refactors
V2 with refactors
This commit is contained in:
166
container/CLAUDE.md
Normal file
166
container/CLAUDE.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Main
|
||||
|
||||
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
|
||||
|
||||
## What You Can Do
|
||||
|
||||
- Answer questions and have conversations
|
||||
- Search the web and fetch content from URLs
|
||||
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
|
||||
- Read and write files in your workspace
|
||||
- Run bash commands in your sandbox
|
||||
- Schedule tasks to run later or on a recurring basis
|
||||
- Send messages back to the chat
|
||||
|
||||
## Communication
|
||||
|
||||
Be concise — every message costs the reader's attention.
|
||||
|
||||
### Destinations
|
||||
|
||||
Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `<message to="name">...</message>` block:
|
||||
|
||||
```
|
||||
<message to="family">On my way home, 15 minutes</message>
|
||||
<message to="worker-1">kick off the pipeline</message>
|
||||
```
|
||||
|
||||
Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name.
|
||||
|
||||
### Mid-turn updates
|
||||
|
||||
Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
|
||||
|
||||
- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response.
|
||||
- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message.
|
||||
- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
|
||||
|
||||
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
|
||||
|
||||
**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did.
|
||||
|
||||
### Internal thoughts
|
||||
|
||||
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `<message>` blocks is also treated as scratchpad. With a single destination, only explicit `<internal>` tags are scratchpad; the rest of your response is sent.
|
||||
|
||||
```
|
||||
<internal>Compiled all three reports, ready to summarize.</internal>
|
||||
|
||||
Here are the key findings from the research…
|
||||
```
|
||||
|
||||
### Sub-agents and teammates
|
||||
|
||||
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
|
||||
|
||||
## Your Workspace
|
||||
|
||||
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
|
||||
|
||||
## Memory
|
||||
|
||||
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
|
||||
|
||||
When you learn something important:
|
||||
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
|
||||
- Split files larger than 500 lines into folders
|
||||
- Keep an index in your memory for the files you create
|
||||
|
||||
## Message Formatting
|
||||
|
||||
Format messages based on the channel you're responding to. Check your group folder name:
|
||||
|
||||
### Slack channels (folder starts with `slack_`)
|
||||
|
||||
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
|
||||
- `*bold*` (single asterisks)
|
||||
- `_italic_` (underscores)
|
||||
- `<https://url|link text>` for links (NOT `[text](url)`)
|
||||
- `•` bullets (no numbered lists)
|
||||
- `:emoji:` shortcodes
|
||||
- `>` for block quotes
|
||||
- No `##` headings — use `*Bold text*` instead
|
||||
|
||||
### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`)
|
||||
|
||||
- `*bold*` (single asterisks, NEVER **double**)
|
||||
- `_italic_` (underscores)
|
||||
- `•` bullet points
|
||||
- ` ``` ` code blocks
|
||||
|
||||
No `##` headings. No `[links](url)`. No `**double stars**`.
|
||||
|
||||
### Discord channels (folder starts with `discord_`)
|
||||
|
||||
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
|
||||
|
||||
---
|
||||
|
||||
## Installing Packages & Tools
|
||||
|
||||
Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools:
|
||||
|
||||
1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval.
|
||||
2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes.
|
||||
|
||||
Example flow:
|
||||
```
|
||||
install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" })
|
||||
# → Admin gets an approval card → approves
|
||||
request_rebuild({ reason: "Apply ffmpeg + transformers" })
|
||||
# → Admin approves → image rebuilt with the packages
|
||||
```
|
||||
|
||||
**When to use this vs workspace pnpm install:**
|
||||
- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies
|
||||
- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH
|
||||
|
||||
### MCP Servers
|
||||
|
||||
Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.:
|
||||
|
||||
```
|
||||
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
|
||||
request_rebuild({ reason: "Add memory MCP server" })
|
||||
```
|
||||
|
||||
## Task Scripts
|
||||
|
||||
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`.
|
||||
|
||||
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows.
|
||||
|
||||
Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
|
||||
|
||||
### How it works
|
||||
|
||||
1. You provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first (30-second timeout)
|
||||
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script in your sandbox to verify it works:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
|
||||
|
||||
- Explain that each wake-up uses API credits and risks rate limits
|
||||
- Suggest restructuring with a script that checks the condition first
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
@@ -3,8 +3,12 @@
|
||||
# Runs Claude Agent SDK in isolated Linux VM with browser automation.
|
||||
#
|
||||
# Runtime split:
|
||||
# - agent-runner (our TypeScript code): Bun
|
||||
# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host
|
||||
# - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node
|
||||
#
|
||||
# Source is never baked in — /app/src is provided by a shared read-only
|
||||
# bind mount at runtime (see src/container-runner.ts). Source-only changes
|
||||
# never require an image rebuild.
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
@@ -66,43 +70,46 @@ RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \
|
||||
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \
|
||||
rm -rf /root/.bun
|
||||
|
||||
# ---- pnpm + global Node CLIs -------------------------------------------------
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# agent-browser has a postinstall build script — pnpm skips these by default.
|
||||
# Allowlist it via .npmrc so the install doesn't silently produce a broken
|
||||
# package. Pinned versions so every rebuild is reproducible.
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \
|
||||
pnpm install -g \
|
||||
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
||||
"vercel@${VERCEL_VERSION}"
|
||||
|
||||
# ---- agent-runner ------------------------------------------------------------
|
||||
# ---- agent-runner deps -------------------------------------------------------
|
||||
# Deps are cached independently of CLI versions. Source is NOT baked in —
|
||||
# it's provided by the shared RO mount at runtime.
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifest + lockfile first so the install layer caches independently of
|
||||
# source edits.
|
||||
COPY agent-runner/package.json agent-runner/bun.lock ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
bun install --frozen-lockfile
|
||||
|
||||
# Source. Bun runs TS directly — no tsc build step. The host remounts this
|
||||
# path at runtime via `src/container-runner.ts` so source edits on the host
|
||||
# take effect without rebuilding the image; the baked copy is the fallback.
|
||||
COPY agent-runner/ ./
|
||||
# ---- pnpm + global Node CLIs -------------------------------------------------
|
||||
# Most stable first, most frequently bumped last. Bumping claude-code
|
||||
# (the most common change) only invalidates one layer.
|
||||
#
|
||||
# only-built-dependencies gates pnpm's supply-chain policy:
|
||||
# - agent-browser has a postinstall build step.
|
||||
# - @anthropic-ai/claude-code's postinstall downloads the native Claude
|
||||
# binary (linux-arm64 variant on our image). Without the allowlist
|
||||
# the SDK fails at spawn time with "native binary not found".
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \
|
||||
pnpm install -g "vercel@${VERCEL_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
|
||||
# ---- Entrypoint --------------------------------------------------------------
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# ---- Workspace + permissions -------------------------------------------------
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra && \
|
||||
RUN mkdir -p /workspace/group /workspace/extra && \
|
||||
chown -R node:node /workspace && \
|
||||
chmod 755 /home/node
|
||||
|
||||
|
||||
55
container/agent-runner/src/config.ts
Normal file
55
container/agent-runner/src/config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Runner config — reads /workspace/agent/container.json at startup.
|
||||
*
|
||||
* This file is mounted read-only inside the container. The host writes it;
|
||||
* the runner only reads. All NanoClaw-specific configuration lives here
|
||||
* instead of environment variables.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
const CONFIG_PATH = '/workspace/agent/container.json';
|
||||
|
||||
export interface RunnerConfig {
|
||||
provider: string;
|
||||
assistantName: string;
|
||||
groupName: string;
|
||||
agentGroupId: string;
|
||||
maxMessagesPerPrompt: number;
|
||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 10;
|
||||
|
||||
let _config: RunnerConfig | null = null;
|
||||
|
||||
/**
|
||||
* Load config from container.json. Called once at startup.
|
||||
* Falls back to sensible defaults for any missing field.
|
||||
*/
|
||||
export function loadConfig(): RunnerConfig {
|
||||
if (_config) return _config;
|
||||
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
} catch {
|
||||
console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`);
|
||||
}
|
||||
|
||||
_config = {
|
||||
provider: (raw.provider as string) || 'claude',
|
||||
assistantName: (raw.assistantName as string) || '',
|
||||
groupName: (raw.groupName as string) || '',
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||
};
|
||||
|
||||
return _config;
|
||||
}
|
||||
|
||||
/** Get the loaded config. Throws if loadConfig() hasn't been called. */
|
||||
export function getConfig(): RunnerConfig {
|
||||
if (!_config) throw new Error('Config not loaded — call loadConfig() first');
|
||||
return _config;
|
||||
}
|
||||
@@ -31,8 +31,7 @@ let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
|
||||
/** Inbound DB — container opens read-only (host is the sole writer). */
|
||||
export function getInboundDb(): Database {
|
||||
if (!_inbound) {
|
||||
const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH;
|
||||
_inbound = new Database(dbPath, { readonly: true });
|
||||
_inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
_inbound.exec('PRAGMA busy_timeout = 5000');
|
||||
}
|
||||
return _inbound;
|
||||
@@ -41,8 +40,7 @@ export function getInboundDb(): Database {
|
||||
/** Outbound DB — container owns this file (sole writer). */
|
||||
export function getOutboundDb(): Database {
|
||||
if (!_outbound) {
|
||||
const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH;
|
||||
_outbound = new Database(dbPath);
|
||||
_outbound = new Database(DEFAULT_OUTBOUND_PATH);
|
||||
_outbound.exec('PRAGMA journal_mode = DELETE');
|
||||
_outbound.exec('PRAGMA busy_timeout = 5000');
|
||||
_outbound.exec('PRAGMA foreign_keys = ON');
|
||||
@@ -122,7 +120,7 @@ export function clearContainerToolInFlight(): void {
|
||||
* A file touch is cheaper and avoids cross-boundary DB write contention.
|
||||
*/
|
||||
export function touchHeartbeat(): void {
|
||||
const p = process.env.SESSION_HEARTBEAT_PATH || _heartbeatPath;
|
||||
const p = _heartbeatPath;
|
||||
const now = new Date();
|
||||
try {
|
||||
fs.utimesSync(p, now, now);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* The container never writes to inbound.db — all status tracking goes through
|
||||
* processing_ack. The host reads processing_ack to sync message lifecycle.
|
||||
*/
|
||||
import { getConfig } from '../config.js';
|
||||
import { getInboundDb, getOutboundDb } from './connection.js';
|
||||
|
||||
export interface MessageInRow {
|
||||
@@ -26,14 +27,16 @@ export interface MessageInRow {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Cap on how many messages reach the agent in one prompt, including any
|
||||
// accumulated-but-not-triggered context. Host controls the cap via the
|
||||
// NANOCLAW_MAX_MESSAGES_PER_PROMPT env var; default mirrors the host's
|
||||
// config.ts default of 10.
|
||||
const MAX_MESSAGES_PER_PROMPT = Math.max(
|
||||
1,
|
||||
parseInt(process.env.NANOCLAW_MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
|
||||
);
|
||||
// Cap on how many messages reach the agent in one prompt. Read from
|
||||
// container.json; falls back to 10.
|
||||
function getMaxMessagesPerPrompt(): number {
|
||||
try {
|
||||
return getConfig().maxMessagesPerPrompt;
|
||||
} catch {
|
||||
// Config not loaded yet (e.g. test harness) — use default
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending messages that are due for processing.
|
||||
@@ -58,7 +61,7 @@ export function getPendingMessages(): MessageInRow[] {
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(MAX_MESSAGES_PER_PROMPT) as MessageInRow[];
|
||||
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ export function categorizeMessage(msg: MessageInRow): CommandInfo {
|
||||
return { category: 'passthrough', command, text, senderId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow check for /clear — the only command the runner handles directly.
|
||||
* All other command gating (filtered, admin) is done by the host router
|
||||
* before messages reach the container.
|
||||
*/
|
||||
export function isClearCommand(msg: MessageInRow): boolean {
|
||||
const content = parseContent(msg.content);
|
||||
const text = (content.text || '').trim();
|
||||
return text.toLowerCase().startsWith('/clear');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractSenderId(msg: MessageInRow, content: any): string | null {
|
||||
const raw: string | null = content?.senderId || content?.author?.userId || null;
|
||||
|
||||
@@ -4,14 +4,8 @@
|
||||
* Runs inside a container. All IO goes through the session DB.
|
||||
* No stdin, no stdout markers, no IPC files.
|
||||
*
|
||||
* Config:
|
||||
* - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db)
|
||||
* - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db)
|
||||
* - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat)
|
||||
* - AGENT_PROVIDER: any registered provider name (default: claude). The
|
||||
* set of registered providers is whatever `providers/index.ts` imports.
|
||||
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
|
||||
* - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands
|
||||
* Config is read from /workspace/agent/container.json (mounted RO).
|
||||
* Only TZ and OneCLI networking vars come from env.
|
||||
*
|
||||
* Mount structure:
|
||||
* /workspace/
|
||||
@@ -19,14 +13,19 @@
|
||||
* outbound.db ← container-owned session DB
|
||||
* .heartbeat ← container touches for liveness detection
|
||||
* outbox/ ← outbound files
|
||||
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
|
||||
* .claude/ ← Claude SDK session data
|
||||
* agent/ ← agent group folder (CLAUDE.md, container.json, working files)
|
||||
* container.json ← per-group config (RO nested mount)
|
||||
* global/ ← shared global memory (RO)
|
||||
* /app/src/ ← shared agent-runner source (RO)
|
||||
* /app/skills/ ← shared skills (RO)
|
||||
* /home/node/.claude/ ← Claude SDK state + skill symlinks (RW)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { loadConfig } from './config.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
// Providers barrel — each enabled provider self-registers on import.
|
||||
// Provider skills append imports to providers/index.ts.
|
||||
@@ -41,21 +40,16 @@ function log(msg: string): void {
|
||||
const CWD = '/workspace/agent';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const providerName = (process.env.AGENT_PROVIDER || 'claude').toLowerCase() as ProviderName;
|
||||
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
|
||||
const adminUserIds = new Set(
|
||||
(process.env.NANOCLAW_ADMIN_USER_IDS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const config = loadConfig();
|
||||
const providerName = config.provider.toLowerCase() as ProviderName;
|
||||
|
||||
log(`Starting v2 agent-runner (provider: ${providerName})`);
|
||||
|
||||
// Destinations addendum is the only runtime-generated context we inject.
|
||||
// Global CLAUDE.md is loaded by Claude Code from /workspace/agent/CLAUDE.md
|
||||
// (which imports /workspace/global/CLAUDE.md via @-syntax) — no need to
|
||||
// read it manually anymore.
|
||||
// Agent instructions are loaded by Claude Code from /workspace/agent/CLAUDE.md
|
||||
// (host-composed at spawn, imports /app/CLAUDE.md and fragments) plus
|
||||
// /workspace/agent/CLAUDE.local.md (agent memory) — no need to read them
|
||||
// manually.
|
||||
const instructions = buildSystemPromptAddendum();
|
||||
|
||||
// Discover additional directories mounted at /workspace/extra/*
|
||||
@@ -77,34 +71,22 @@ async function main(): Promise<void> {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.ts');
|
||||
|
||||
// Build MCP servers config: nanoclaw built-in + any additional from host
|
||||
// Build MCP servers config: nanoclaw built-in + any from container.json
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {
|
||||
SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db',
|
||||
SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db',
|
||||
SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat',
|
||||
},
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Merge additional MCP servers from host configuration
|
||||
if (process.env.NANOCLAW_MCP_SERVERS) {
|
||||
try {
|
||||
const additional = JSON.parse(process.env.NANOCLAW_MCP_SERVERS) as Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
for (const [name, config] of Object.entries(additional)) {
|
||||
mcpServers[name] = config;
|
||||
log(`Additional MCP server: ${name} (${config.command})`);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Failed to parse NANOCLAW_MCP_SERVERS: ${e}`);
|
||||
}
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
mcpServers[name] = serverConfig;
|
||||
log(`Additional MCP server: ${name} (${serverConfig.command})`);
|
||||
}
|
||||
|
||||
const provider = createProvider(providerName, {
|
||||
assistantName,
|
||||
assistantName: config.assistantName || undefined,
|
||||
mcpServers,
|
||||
env: { ...process.env },
|
||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||
@@ -114,7 +96,6 @@ async function main(): Promise<void> {
|
||||
provider,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
adminUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getPendingMessages, markProcessing, markCompleted, type MessageInRow }
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
@@ -23,12 +23,6 @@ export interface PollLoopConfig {
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
};
|
||||
/**
|
||||
* Set of user IDs allowed to run admin commands (e.g. /clear) in this
|
||||
* agent group. Host populates from owners + global admins + scoped admins
|
||||
* at container wake time, so role changes take effect on next spawn.
|
||||
*/
|
||||
adminUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,74 +84,36 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
|
||||
const routing = extractRouting(messages);
|
||||
|
||||
// Handle commands: categorize chat messages
|
||||
const adminUserIds = config.adminUserIds ?? new Set<string>();
|
||||
const normalMessages = [];
|
||||
// Command handling: the host router gates filtered and unauthorized
|
||||
// admin commands before they reach the container. The only command
|
||||
// the runner handles directly is /clear (session reset).
|
||||
const normalMessages: MessageInRow[] = [];
|
||||
const commandIds: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') {
|
||||
normalMessages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cmdInfo = categorizeMessage(msg);
|
||||
|
||||
if (cmdInfo.category === 'filtered') {
|
||||
// Silently drop — mark completed, don't process
|
||||
log(`Filtered command: ${cmdInfo.command} (msg: ${msg.id})`);
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: 'Session cleared.' }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmdInfo.category === 'admin') {
|
||||
if (!cmdInfo.senderId || !adminUserIds.has(cmdInfo.senderId)) {
|
||||
log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: `Permission denied: ${cmdInfo.command} requires admin access.` }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
// Handle admin commands directly
|
||||
if (cmdInfo.command === '/clear') {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: 'Session cleared.' }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other admin commands — pass through to agent
|
||||
normalMessages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// passthrough or none
|
||||
normalMessages.push(msg);
|
||||
}
|
||||
|
||||
// Mark filtered/denied command messages as completed immediately
|
||||
if (commandIds.length > 0) {
|
||||
markCompleted(commandIds);
|
||||
}
|
||||
|
||||
// If all messages were filtered commands, skip processing
|
||||
if (normalMessages.length === 0) {
|
||||
// Mark remaining processing IDs as completed
|
||||
const remainingIds = ids.filter((id) => !commandIds.includes(id));
|
||||
if (remainingIds.length > 0) markCompleted(remainingIds);
|
||||
log(`All ${messages.length} message(s) were commands, skipping query`);
|
||||
@@ -204,7 +160,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
try {
|
||||
const result = await processQuery(query, routing);
|
||||
const result = await processQuery(query, routing, processingIds);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setStoredSessionId(continuation);
|
||||
@@ -233,6 +189,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure completed even if processQuery ended without a result event
|
||||
// (e.g. stream closed unexpectedly).
|
||||
markCompleted(processingIds);
|
||||
log(`Completed ${ids.length} message(s)`);
|
||||
}
|
||||
@@ -276,7 +234,11 @@ interface QueryResult {
|
||||
continuation?: string;
|
||||
}
|
||||
|
||||
async function processQuery(query: AgentQuery, routing: RoutingContext): Promise<QueryResult> {
|
||||
async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
|
||||
@@ -289,18 +251,16 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise
|
||||
const pollHandle = setInterval(() => {
|
||||
if (done) return;
|
||||
|
||||
// Skip system messages (MCP tool responses) and admin commands (need fresh query).
|
||||
// Also defer messages whose thread_id differs from the active turn's routing
|
||||
// — mixing threads into one streaming turn would send the reply to the wrong
|
||||
// thread because `routing` is captured at turn start. The next turn will pick
|
||||
// them up with fresh routing.
|
||||
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
const newMessages = getPendingMessages().filter((m) => {
|
||||
if (m.kind === 'system') return false;
|
||||
if (m.kind === 'chat' || m.kind === 'chat-sdk') {
|
||||
const cmd = categorizeMessage(m);
|
||||
if (cmd.category === 'admin') return false;
|
||||
}
|
||||
if ((m.thread_id ?? null) !== (routing.threadId ?? null)) return false;
|
||||
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
|
||||
return true;
|
||||
});
|
||||
if (newMessages.length > 0) {
|
||||
@@ -329,8 +289,17 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise
|
||||
// effectively orphaned and the next message started a blank
|
||||
// Claude session with no prior context.
|
||||
setStoredSessionId(event.continuation);
|
||||
} else if (event.type === 'result' && event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
} else if (event.type === 'result') {
|
||||
// A result — with or without text — means the turn is done. Mark
|
||||
// the initial batch completed now so the host sweep doesn't see
|
||||
// stale 'processing' claims while the query stays open for
|
||||
// follow-up pushes. The agent may have responded via MCP
|
||||
// (send_message) mid-turn, or the message may not need a response
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
146
docs/claude-md-composition.md
Normal file
146
docs/claude-md-composition.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# CLAUDE.md Composition
|
||||
|
||||
Compose agent instructions from a shared base, skill/tool fragments, and per-group memory — replacing the current per-group CLAUDE.md with a host-regenerated entry point.
|
||||
|
||||
## Problem
|
||||
|
||||
Today each agent group has a single RW `groups/<folder>/CLAUDE.md`, written once at init and never updated. Consequences:
|
||||
|
||||
- Upstream improvements to shared agent guidance don't propagate to existing groups
|
||||
- No way to ship tool-specific guidance with the tool itself (e.g., an agent-browser usage fragment)
|
||||
- Human-authored identity and agent-accumulated memory live in the same file with no separation
|
||||
- The `.claude-global.md` symlink + `groups/global/CLAUDE.md` pattern handled the shared base but not per-module fragments
|
||||
|
||||
## Design
|
||||
|
||||
**Principle: RW = per-group memory, RO = shared content.** Same rule that governs the shared-source refactor, applied to agent instructions.
|
||||
|
||||
### Three tiers
|
||||
|
||||
| Tier | File | Location | Mount | Editor | Change rate |
|
||||
|---|---|---|---|---|---|
|
||||
| **Shared base** | `CLAUDE.md` | `container/CLAUDE.md` | RO at `/app/CLAUDE.md` | Owner (via git) | Rare |
|
||||
| **Module fragments** | `instructions.md` | Inside each module | RO via shared skills mount, or inline in `container.json` | Module author | Ships with module |
|
||||
| **Per-group memory** | `CLAUDE.local.md` | `groups/<folder>/` | RW at `/workspace/agent/` | Agent + owner | Continuous |
|
||||
| **Composed entry** | `CLAUDE.md` | `groups/<folder>/` | RW but host-regenerated | **Host, not human** | Every spawn |
|
||||
|
||||
### Composition
|
||||
|
||||
At every spawn, the host regenerates `groups/<folder>/CLAUDE.md` as an import-only file:
|
||||
|
||||
```markdown
|
||||
<!-- Composed at spawn — do not edit. Edit CLAUDE.local.md for per-group content. -->
|
||||
@./.claude-shared.md
|
||||
@./.claude-fragments/welcome.md
|
||||
@./.claude-fragments/agent-browser.md
|
||||
@./.claude-fragments/<enabled-skill-with-fragment>.md
|
||||
@./.claude-fragments/mcp-<server-name>.md
|
||||
```
|
||||
|
||||
Symlinks are created alongside, following the `.claude-global.md` pattern (dangling on host, valid in container via the RO mount):
|
||||
|
||||
- `groups/<folder>/.claude-shared.md` → `/app/CLAUDE.md`
|
||||
- `groups/<folder>/.claude-fragments/<name>.md` → `/app/skills/<name>/instructions.md` (for each enabled skill that ships a fragment)
|
||||
|
||||
Claude Code auto-loads `CLAUDE.local.md` from cwd without an import line — native behavior. Agent memory works natively; composition only wraps around it.
|
||||
|
||||
### Module fragment contract
|
||||
|
||||
**Skills.** A skill optionally ships an `instructions.md` at the top of its directory:
|
||||
|
||||
```
|
||||
container/skills/welcome/
|
||||
SKILL.md — description + when-to-use (existing)
|
||||
instructions.md — always-in-context guidance (optional, new)
|
||||
```
|
||||
|
||||
When the skill is enabled for a group, the host imports `instructions.md` into the composed CLAUDE.md. `SKILL.md` semantics are unchanged — Claude Code still uses it for skill discovery and on-demand invocation. Most skills won't need an `instructions.md` (SKILL.md is sufficient for on-demand skills); it's only for guidance that should be in context at all times.
|
||||
|
||||
**MCP servers.** A `container.json` MCP server entry can contribute a fragment inline:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-db": {
|
||||
"command": "...",
|
||||
"instructions": "Read-only access to the production DB. Never run UPDATE/DELETE without admin approval."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Host writes the inline content to `.claude-fragments/mcp-<server-name>.md` at spawn and imports it.
|
||||
|
||||
**Global CLIs baked into the image** (agent-browser, vercel, claude-code) have always-present guidance; it belongs in `container/CLAUDE.md`, not as a conditional fragment. Don't try to make universally-present tools dynamic.
|
||||
|
||||
### Identity vs memory
|
||||
|
||||
All per-group content — human-authored identity ("you are the research agent, be terse") and agent-accumulated memory (inventories, user preferences, learned patterns) — lives in a single `CLAUDE.local.md`. Both humans and agents can edit it.
|
||||
|
||||
If the distinction becomes operationally important later (agents confused about what they were told vs. what they learned), split into `identity.md` (human-authored, imported into composed CLAUDE.md) + `CLAUDE.local.md` (agent memory only). Starting with one file.
|
||||
|
||||
## Changes
|
||||
|
||||
### `container/CLAUDE.md` (new)
|
||||
|
||||
Write the shared base: general NanoClaw context, how to engage with users, output conventions, anything that should apply to every agent across every group. Seed from current `groups/global/CLAUDE.md`.
|
||||
|
||||
### `container/skills/<name>/instructions.md` (optional, per skill)
|
||||
|
||||
Add for any skill that warrants always-in-context guidance. Optional.
|
||||
|
||||
### `container.json` schema
|
||||
|
||||
Add optional `instructions` field (string) to each MCP server entry.
|
||||
|
||||
### `container-runner.ts` spawn-time sync
|
||||
|
||||
Extend the skill-symlink sync function (added in the shared-source refactor) to also compose CLAUDE.md. On every spawn:
|
||||
|
||||
1. Sync `.claude-shared/skills/<name>` symlinks from `container.json` skill selection.
|
||||
2. Sync `.claude-shared.md` symlink → `/app/CLAUDE.md`.
|
||||
3. For each enabled skill with an `instructions.md`, create `.claude-fragments/<name>.md` symlink → `/app/skills/<name>/instructions.md`.
|
||||
4. For each `container.json` MCP server with an `instructions` field, write the inline content to `.claude-fragments/mcp-<server-name>.md`.
|
||||
5. Write `groups/<folder>/CLAUDE.md` atomically (temp + rename) with import lines in a deterministic order: shared base → skill fragments (alphabetical) → MCP fragments (alphabetical).
|
||||
6. Remove stale symlinks and fragment files for modules no longer enabled.
|
||||
|
||||
### `group-init.ts`
|
||||
|
||||
- Stop writing an initial `groups/<folder>/CLAUDE.md` at group creation — host regenerates at first spawn.
|
||||
- Stop creating the `.claude-global.md` symlink — replaced by `.claude-shared.md` in the composition step.
|
||||
- Optionally create an empty `groups/<folder>/CLAUDE.local.md` at init as a clear affordance for humans and agents.
|
||||
|
||||
### `groups/global/`
|
||||
|
||||
Eliminate. The shared base moves to `container/CLAUDE.md`. Any deployment-specific overrides live in the owner's customized `container/CLAUDE.md` (same pattern as any other codebase customization).
|
||||
|
||||
## Migration
|
||||
|
||||
Breaking change, one-time cutover:
|
||||
|
||||
- For every group, rename `groups/<folder>/CLAUDE.md` → `groups/<folder>/CLAUDE.local.md`. Preserves all existing per-group content as memory.
|
||||
- Move content from `groups/global/CLAUDE.md` (beyond the default stub) into `container/CLAUDE.md`. Delete `groups/global/`.
|
||||
- Delete stale `.claude-global.md` symlinks in each group dir — the spawn pass creates `.claude-shared.md` instead.
|
||||
- First spawn after cutover regenerates `CLAUDE.md` with proper imports.
|
||||
|
||||
## Interaction with shared-source refactor
|
||||
|
||||
This refactor depends on the shared skills mount (`/app/skills/` RO) from the shared-source refactor landing first. It extends the spawn-time sync from "just skill symlinks" to "skill symlinks + CLAUDE.md composition" — both passes share the same helper.
|
||||
|
||||
After this refactor, the "Personality / instructions" row in the shared-source per-group customization table splits:
|
||||
|
||||
| Resource | Location | Mechanism |
|
||||
|----------|----------|-----------|
|
||||
| Agent memory | `groups/<folder>/CLAUDE.local.md` | RW at `/workspace/agent/`, auto-loaded by Claude Code |
|
||||
| Composed entry | `groups/<folder>/CLAUDE.md` | Host-regenerated at every spawn |
|
||||
|
||||
## What triggers what
|
||||
|
||||
| Change | Action | Scope |
|
||||
|--------|--------|-------|
|
||||
| Edit `container/CLAUDE.md` | Kill running containers (next spawn recomposes) | All groups |
|
||||
| Add/edit a skill's `instructions.md` | Kill running containers | All groups with the skill enabled |
|
||||
| Enable/disable a skill in `container.json` | Kill that group's containers | One group |
|
||||
| Add MCP server with `instructions` field | Kill that group's containers | One group |
|
||||
| Edit `CLAUDE.local.md` | Nothing — live via RW mount; Claude Code re-reads at next prompt | One group |
|
||||
| Add a new agent group | Spawn writes `CLAUDE.md` fresh from the composition pass | One group |
|
||||
270
docs/shared-source.md
Normal file
270
docs/shared-source.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Shared Source
|
||||
|
||||
Replace per-group agent-runner-src copies with a single shared read-only mount.
|
||||
|
||||
## Problem
|
||||
|
||||
Each agent group gets a full copy of `container/agent-runner/src/` at creation time. This copy is mounted RW at `/app/src` in the container. Consequences:
|
||||
|
||||
- Bug fixes and features don't propagate to existing groups
|
||||
- Owner edits to `container/agent-runner/src/` silently don't apply to existing groups
|
||||
- No tooling to diff or detect drift between groups and upstream
|
||||
- The RW mount lets agents write to their own runtime source without approval
|
||||
- Cross-cutting changes (host + container) break down when container code is per-group
|
||||
- Skills have the same copy-and-drift problem
|
||||
|
||||
## Design
|
||||
|
||||
**Principle: RW is per-group, RO is shared.** Every mount is either read-only and shared across all groups, or read-write and scoped to one group. Source and skills become RO + shared. Personality, config, working files, and Claude state stay RW + per-group. This makes drift impossible by construction — no group can diverge from shared code because no group has write access to it.
|
||||
|
||||
### Shared source mount
|
||||
|
||||
Mount `container/agent-runner/src/` into all containers at `/app/src` as **read-only**.
|
||||
|
||||
```
|
||||
container/agent-runner/src/ → /app/src (RO, shared)
|
||||
```
|
||||
|
||||
Source is never baked into the image. `/app/src/` exists only via this mount — running without it is an intentional startup failure (entrypoint `bun run /app/src/index.ts` → ENOENT). Source-only changes never trigger image rebuilds; edits to `.ts` files take effect on next container spawn.
|
||||
|
||||
Image rebuilds are only needed for:
|
||||
- Agent-runner npm dependency changes (`package.json` / `bun.lock`)
|
||||
- System packages, runtime versions, global CLI version bumps
|
||||
- Dockerfile/entrypoint changes
|
||||
|
||||
### Shared skills mount
|
||||
|
||||
Mount `container/skills/` into all containers at `/app/skills/` as **read-only**.
|
||||
|
||||
Per-group skill selection via `container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"skills": ["welcome", "agent-browser", "self-customize"]
|
||||
// or "skills": "all" (default)
|
||||
}
|
||||
```
|
||||
|
||||
At every spawn, the host syncs symlinks in the group's `.claude-shared/skills/` directory to match the selected set. For `"all"`, the set is recomputed from the shared skills dir on each spawn — newly-added upstream skills appear without intervention. Symlinks for skills no longer in the set are removed.
|
||||
|
||||
Each symlink points to a container path:
|
||||
|
||||
```
|
||||
.claude-shared/skills/welcome → /app/skills/welcome
|
||||
.claude-shared/skills/agent-browser → /app/skills/agent-browser
|
||||
```
|
||||
|
||||
Claude Code scans `/home/node/.claude/skills/`, follows the symlinks, loads the selected skills. Same dangling-symlink-on-host pattern as `.claude-global.md` — host tools don't resolve the target, the container mount makes it valid at read time.
|
||||
|
||||
### Per-group customization surface
|
||||
|
||||
What remains per-group (unchanged):
|
||||
|
||||
| Resource | Location | Mechanism |
|
||||
|----------|----------|-----------|
|
||||
| Personality / instructions | `groups/<folder>/CLAUDE.md` | Mount at `/workspace/agent` (RW, live) |
|
||||
| MCP servers | `groups/<folder>/container.json` | Env var at spawn |
|
||||
| apt/npm packages | `groups/<folder>/container.json` | Per-group image layer |
|
||||
| Skill selection | `groups/<folder>/container.json` | Symlinks at spawn |
|
||||
| Additional mounts | `groups/<folder>/container.json` | Validated bind mounts |
|
||||
| Agent provider / model | `groups/<folder>/container.json` | Read by runner at startup |
|
||||
| Claude Code settings | `.claude-shared/settings.json` | Mount at `/home/node/.claude` (RW) |
|
||||
| Working files | `groups/<folder>/` | Mount at `/workspace/agent` (RW) |
|
||||
|
||||
### Self-modification
|
||||
|
||||
Existing config-level self-mod tools (`install_packages`, `add_mcp_server`, `request_rebuild`) mutate `container.json` and per-group images, not source. Unchanged — stays per-group.
|
||||
|
||||
Source-level self-modification (not yet implemented) uses staging: edits happen against a copy of `container/agent-runner/src/`, reviewed and swapped in on approval. Owner can also edit source directly.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Env is for things read by code we don't own: glibc, Node's http agent, CLIs we shell out to. Everything NanoClaw-specific moves out of env.
|
||||
|
||||
**Stays in env (read by non-nanoclaw code):**
|
||||
|
||||
| Var | Reader |
|
||||
|---|---|
|
||||
| `TZ` | glibc, child processes |
|
||||
| `HTTPS_PROXY`, `NO_PROXY` | Node http agent, curl, git, etc. (OneCLI-injected) |
|
||||
| `NODE_EXTRA_CA_CERTS` | Node at startup (OneCLI-injected) |
|
||||
|
||||
**Moves to `container.json` (read by runner at startup):**
|
||||
|
||||
| Var | Reason |
|
||||
|---|---|
|
||||
| `AGENT_PROVIDER` | Per-group config; runner reads before importing provider module |
|
||||
| `NANOCLAW_AGENT_GROUP_NAME` | Per-group identity |
|
||||
| `NANOCLAW_ASSISTANT_NAME` | Per-group identity |
|
||||
| `NANOCLAW_MAX_MESSAGES_PER_PROMPT` | Config constant; per-group override possible |
|
||||
|
||||
**Deleted (admin gating moves to router):**
|
||||
|
||||
`NANOCLAW_ADMIN_USER_IDS` is removed entirely — not moved to a new location. The container no longer makes authorization decisions. See **Router command gate** below.
|
||||
|
||||
**Hardcoded as conventions:**
|
||||
|
||||
| Var | Convention |
|
||||
|---|---|
|
||||
| `SESSION_INBOUND_DB_PATH` | `/workspace/inbound.db` |
|
||||
| `SESSION_OUTBOUND_DB_PATH` | `/workspace/outbound.db` |
|
||||
| `SESSION_HEARTBEAT_PATH` | `/workspace/.heartbeat` |
|
||||
| `NANOCLAW_AGENT_GROUP_ID` | Read from `/workspace/agent/container.json` at startup |
|
||||
|
||||
### Runner startup order
|
||||
|
||||
The runner can no longer assume DB paths or provider identity are handed to it in env. Revised startup:
|
||||
|
||||
1. Set up logging.
|
||||
2. Read `/workspace/agent/container.json` (mounted RW but read-only here).
|
||||
3. Open `/workspace/inbound.db` and `/workspace/outbound.db` (fixed paths).
|
||||
4. Read bootstrap tables from `inbound.db` (destinations).
|
||||
5. Import the provider module selected by `container.json`.
|
||||
6. Enter the poll loop.
|
||||
|
||||
### Router command gate
|
||||
|
||||
The host router gates slash commands before writing to `messages_in`. The container still handles whatever reaches it; it just stops making authorization decisions.
|
||||
|
||||
1. **Filtered commands** (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. Never reach the container.
|
||||
2. **Admin commands** (`/clear`, `/compact`, `/context`, `/cost`, `/files`) → check sender against `user_roles` (owners + global admins + admins scoped to this agent group).
|
||||
- Denied: write "Permission denied: `<cmd>` requires admin access." directly to `messages_out` in the same thread. Do not write to `messages_in`.
|
||||
- Allowed: pass through to container unchanged.
|
||||
3. **Normal messages** → pass through unchanged.
|
||||
|
||||
Admin commands that flow through continue to be handled the same way they are today:
|
||||
- `/clear` — container's existing handler in `poll-loop.ts` resets session continuation and writes "Session cleared."
|
||||
- `/compact`, `/context`, `/cost`, `/files` — container forwards them to Claude Code's native slash-command handler.
|
||||
|
||||
Container receives only authorized messages. The runner has no admin concept, no `adminUserIds` field, no admin-gate branch — but it still recognizes `/clear` to reset session state.
|
||||
|
||||
### Scope rules
|
||||
|
||||
Each channel answers a single scope question:
|
||||
|
||||
| Channel | Scope | What it holds |
|
||||
|---|---|---|
|
||||
| Env vars | Process | Things read by code we don't own (`TZ`, `HTTPS_PROXY`) |
|
||||
| `container.json` | Per-group | Per-group config (MCP, packages, provider, model, skills, mounts) |
|
||||
| `inbound.db` / `outbound.db` | Per-session | Messages, session state, and host-projected views of cross-group state (destinations) |
|
||||
| Central DB (`data/v2.db`) | Cross-group | Users, roles, wiring, messaging groups, sessions |
|
||||
|
||||
The runner reads from env (for external-convention vars), `container.json` (for its own group's config), and `inbound.db` (for messages + projected views). It never reads central DB directly — that's always host-projected through inbound.db first.
|
||||
|
||||
After this change, the spawn-time `-e` flags shrink from ~10 to ~3-5 (TZ + OneCLI networking). No `NANOCLAW_*` env var survives.
|
||||
|
||||
## Image layer strategy
|
||||
|
||||
Single Dockerfile with aggressive layer ordering: stable layers first, frequently-bumped layers last. BuildKit's layer cache handles "upstream layers unchanged" rebuilds efficiently — a separate base image isn't justified.
|
||||
|
||||
Two image tags exist at runtime:
|
||||
|
||||
```
|
||||
nanoclaw-agent:latest — shared base (rebuild: dep/CLI bumps + Dockerfile changes)
|
||||
└── nanoclaw-agent:<group> — per-group apt/npm packages (rebuild: per-group via install_packages)
|
||||
```
|
||||
|
||||
Layer order within the base:
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-slim
|
||||
|
||||
# System deps (apt) — rarely change
|
||||
RUN apt-get install ...
|
||||
|
||||
# Bun — pinned version, rarely changes
|
||||
RUN ... bun
|
||||
|
||||
# Agent-runner deps — cached independently of CLI versions
|
||||
COPY agent-runner/package.json agent-runner/bun.lock /app/
|
||||
RUN cd /app && bun install --frozen-lockfile
|
||||
|
||||
# Global CLIs — most stable first, most frequently bumped last
|
||||
RUN pnpm install -g "vercel@${VERCEL_VERSION}"
|
||||
RUN pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
|
||||
RUN pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
```
|
||||
|
||||
Bumping claude-code (the most common change) only rebuilds one layer. Agent-runner deps and other CLIs stay cached.
|
||||
|
||||
Source is never baked into the image — always provided by the shared RO mount at runtime.
|
||||
|
||||
### Agent-triggered version bumps
|
||||
|
||||
Agents can request a claude-code version bump via a new self-mod tool (`bump_claude_code`). Same fire-and-forget pattern as `install_packages`: agent requests → owner approves → host rebuilds base image → kill all running containers. Unlike `install_packages` (per-group image), this rebuilds the shared base image and affects all groups.
|
||||
|
||||
## Changes
|
||||
|
||||
### `group-init.ts`
|
||||
|
||||
- Remove the `agent-runner-src` copy block (lines 109–117)
|
||||
- Remove the `skills/` copy block (lines 100–107)
|
||||
- Skill symlinks are no longer created at init — sync is spawn-owned (see `container-runner.ts`)
|
||||
|
||||
### `container-runner.ts` `buildMounts()`
|
||||
|
||||
- Remove per-group `agent-runner-src` mount (lines 206–209)
|
||||
- Add shared RO mount: `container/agent-runner/src/` → `/app/src`
|
||||
- Add shared RO mount: `container/skills/` → `/app/skills`
|
||||
- Sync skill symlinks in `.claude-shared/skills/` at spawn: write desired set from `container.json` (`"all"` = every skill in the shared dir, recomputed per spawn), remove symlinks not in the set
|
||||
|
||||
### `container-runner.ts` `buildContainerArgs()`
|
||||
|
||||
- Remove `-e SESSION_INBOUND_DB_PATH`, `-e SESSION_OUTBOUND_DB_PATH`, `-e SESSION_HEARTBEAT_PATH` (hardcoded conventions now)
|
||||
- Remove `-e AGENT_PROVIDER` (moves to `container.json`)
|
||||
- Remove `-e NANOCLAW_ASSISTANT_NAME`, `-e NANOCLAW_AGENT_GROUP_ID`, `-e NANOCLAW_AGENT_GROUP_NAME`
|
||||
- Remove `-e NANOCLAW_MAX_MESSAGES_PER_PROMPT`
|
||||
- Remove the `user_roles` join + `-e NANOCLAW_ADMIN_USER_IDS` block (lines 269–287) entirely. Admin gating moves to the router — no admin data passed to the container.
|
||||
- Keep: `-e TZ`, OneCLI-contributed env (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`, `NO_PROXY`)
|
||||
|
||||
### `router.ts` (new command gate)
|
||||
|
||||
- Classify inbound slash commands before writing to `messages_in`: filtered / admin / normal.
|
||||
- Filtered (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently.
|
||||
- Admin commands (`/clear`, `/compact`, `/context`, `/cost`, `/files`) from non-admins → write "Permission denied" directly to `messages_out`, skip `messages_in`.
|
||||
- All authorized messages (admin commands from admins, and normal messages) → pass through unchanged to `messages_in`. Container handles them as today.
|
||||
- The `ADMIN_COMMANDS` and `FILTERED_COMMANDS` lists move from `container/agent-runner/src/formatter.ts` to a host-side module.
|
||||
|
||||
### `container/agent-runner/src/` (runner)
|
||||
|
||||
- New `config.ts` module: loads `/workspace/agent/container.json` at startup, exposes a typed config singleton. All previous `process.env.NANOCLAW_*` reads go through this.
|
||||
- `db/connection.ts`: use hardcoded paths `/workspace/inbound.db` and `/workspace/outbound.db`; drop `SESSION_*_DB_PATH` lookups.
|
||||
- `formatter.ts`: remove `ADMIN_COMMANDS`, `FILTERED_COMMANDS`, and the `filtered` / admin-gate categorization. Keep enough to recognize `/clear` so `poll-loop.ts` can route it (e.g., a narrow `isClearCommand(msg)` helper).
|
||||
- `poll-loop.ts`: remove `adminUserIds` field from config type and the admin-gate branch (lines 113–126). Keep the `/clear` handler (lines 128–142) — `/clear` still flows through from the router.
|
||||
- Provider selection (`providers/index.ts` or equivalent): read provider from config singleton, not env.
|
||||
|
||||
### `container-config.ts`
|
||||
|
||||
- Add `skills` field to `ContainerConfig` (`string[] | "all"`, default `"all"`)
|
||||
- Add fields: `provider`, `groupName`, `assistantName`, `maxMessagesPerPrompt` (optional, falls back to code default)
|
||||
|
||||
### `.env` / `.env.example`
|
||||
|
||||
- Remove any `NANOCLAW_*` entries that were documented as tunables. Update `.env.example` to list only TZ and OneCLI-related vars as valid overrides.
|
||||
|
||||
### DB migration
|
||||
|
||||
- Drop `agent_groups.agent_provider` column and `sessions.agent_provider` column. Source of truth becomes `container.json.provider`.
|
||||
- One-time data migration reads existing values and writes them to each group's `container.json`. Sessions lose any per-session provider override — provider is a per-group property now.
|
||||
|
||||
### Migration
|
||||
|
||||
**This is a breaking change.** Host restart kills all running containers. No gradual rollout. Any code referencing dropped columns or removed env vars must be updated before the migration runs.
|
||||
|
||||
- Provider install skills (`/add-opencode`, `/add-ollama-tool`) now write to the shared `container/agent-runner/src/providers/` tree. The per-group `providers/` overlay pattern is removed. Any uncommitted provider overlays must be upstreamed before cutover.
|
||||
- Delete existing `data/v2-sessions/<id>/agent-runner-src/` directories on first run after cutover.
|
||||
- Existing `.claude-shared/skills/` directories get replaced with symlinks on next spawn.
|
||||
- DB migration (see above) reads `agent_provider` columns and projects into `container.json`, then drops the columns.
|
||||
|
||||
## What triggers what
|
||||
|
||||
| Change | Action needed | Scope |
|
||||
|--------|--------------|-------|
|
||||
| Agent-runner `.ts` source | Kill running containers | All groups |
|
||||
| Agent-runner npm deps | Rebuild `nanoclaw-agent` + kill all | All groups |
|
||||
| System deps, Bun, Node | Rebuild `nanoclaw-agent` + kill all | All groups |
|
||||
| Claude-code version bump | Rebuild `nanoclaw-agent` + kill all | All groups (agent-triggerable) |
|
||||
| Skill content | Kill running containers | All groups |
|
||||
| Per-group apt/npm packages | `buildAgentGroupImage()` + kill | One group |
|
||||
| Per-group config (MCP, mounts, provider, model, skills) | Kill that group's containers | One group |
|
||||
| CLAUDE.md, working files | Nothing (live via RW mount) | One group |
|
||||
@@ -363,7 +363,7 @@ async function sendWelcomeViaCliSocket(
|
||||
to: {
|
||||
channelType: dmMg.channel_type,
|
||||
platformId: dmMg.platform_id,
|
||||
threadId: null,
|
||||
threadId: dmMg.platform_id,
|
||||
},
|
||||
}) + '\n';
|
||||
socket.write(payload, (err) => {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* One-shot migration: wire each existing group up to global memory via
|
||||
* an in-tree symlink + @-import.
|
||||
*
|
||||
* Claude Code's @-import only follows paths inside cwd, so a direct
|
||||
* `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` silently does
|
||||
* nothing (the import line is parsed but the target file is never
|
||||
* loaded into context). The working approach:
|
||||
*
|
||||
* 1. Symlink `groups/<folder>/.claude-global.md` →
|
||||
* `/workspace/global/CLAUDE.md` (container path; dangling on host,
|
||||
* valid inside the container via the /workspace/global mount).
|
||||
* 2. Have the group's CLAUDE.md import the symlink:
|
||||
* `@./.claude-global.md`.
|
||||
*
|
||||
* This script:
|
||||
* - Creates the symlink if missing.
|
||||
* - Replaces any existing broken `@/workspace/global/CLAUDE.md` or
|
||||
* `@../global/CLAUDE.md` import line with the symlink form.
|
||||
* - Prepends the symlink import if neither form is present.
|
||||
* - Skips entirely if `groups/global/CLAUDE.md` doesn't exist.
|
||||
*
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* Usage: pnpm exec tsx scripts/migrate-group-claude-md.ts
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../src/config.js';
|
||||
|
||||
const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md');
|
||||
const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md';
|
||||
const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md';
|
||||
const IMPORT_LINE = `@./${GLOBAL_MEMORY_LINK_NAME}`;
|
||||
|
||||
// Match any existing @-import that points at global/CLAUDE.md, whether
|
||||
// via absolute path, relative path, or the new symlink form.
|
||||
const EXISTING_IMPORT_REGEX =
|
||||
/^@(?:\/workspace\/global\/CLAUDE\.md|\.\.\/global\/CLAUDE\.md|\.\/\.claude-global\.md)\s*$/m;
|
||||
|
||||
if (!fs.existsSync(GLOBAL_CLAUDE_MD)) {
|
||||
console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(GROUPS_DIR)) {
|
||||
console.error(`No groups dir at ${GROUPS_DIR} — nothing to migrate.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true });
|
||||
let updated = 0;
|
||||
let alreadyWired = 0;
|
||||
let missingClaudeMd = 0;
|
||||
let symlinksCreated = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'global') continue;
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, entry.name);
|
||||
|
||||
// Symlink (idempotent — skip if already present)
|
||||
const linkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME);
|
||||
let linkExists = false;
|
||||
try {
|
||||
fs.lstatSync(linkPath);
|
||||
linkExists = true;
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
if (!linkExists) {
|
||||
fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, linkPath);
|
||||
console.log(`[link] ${entry.name}: created ${GLOBAL_MEMORY_LINK_NAME}`);
|
||||
symlinksCreated++;
|
||||
}
|
||||
|
||||
// CLAUDE.md import wiring
|
||||
const claudeMd = path.join(groupDir, 'CLAUDE.md');
|
||||
if (!fs.existsSync(claudeMd)) {
|
||||
console.log(`[skip] ${entry.name}: no CLAUDE.md`);
|
||||
missingClaudeMd++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = fs.readFileSync(claudeMd, 'utf-8');
|
||||
const match = body.match(EXISTING_IMPORT_REGEX);
|
||||
|
||||
if (match && match[0] === IMPORT_LINE) {
|
||||
console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`);
|
||||
alreadyWired++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let newBody: string;
|
||||
if (match) {
|
||||
// Replace the broken import with the working form
|
||||
newBody = body.replace(EXISTING_IMPORT_REGEX, IMPORT_LINE);
|
||||
console.log(`[fix] ${entry.name}: rewrote ${match[0]} → ${IMPORT_LINE}`);
|
||||
} else {
|
||||
// Prepend fresh
|
||||
newBody = `${IMPORT_LINE}\n\n${body}`;
|
||||
console.log(`[ok] ${entry.name}: prepended ${IMPORT_LINE}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(claudeMd, newBody);
|
||||
updated++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd} symlinksCreated=${symlinksCreated}`,
|
||||
);
|
||||
182
src/claude-md-compose.ts
Normal file
182
src/claude-md-compose.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* CLAUDE.md composition for agent groups.
|
||||
*
|
||||
* Replaces the per-group "written once at init, owned by the group" pattern
|
||||
* with a host-regenerated entry point that imports:
|
||||
* - a shared base (`container/CLAUDE.md` mounted RO at `/app/CLAUDE.md`)
|
||||
* - optional per-skill fragments (skills that ship `instructions.md`)
|
||||
* - optional per-MCP-server fragments (inline `instructions` field in
|
||||
* `container.json`)
|
||||
* - per-group agent memory (`CLAUDE.local.md`, auto-loaded by Claude Code)
|
||||
*
|
||||
* Runs on every spawn from `container-runner.buildMounts()`. Deterministic —
|
||||
* same inputs produce the same CLAUDE.md, and stale fragments are pruned.
|
||||
*
|
||||
* See `docs/claude-md-composition.md` for the full design.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import { readContainerConfig } from './container-config.js';
|
||||
import { log } from './log.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
// Symlink targets are container paths — dangling on host (hence the readlink
|
||||
// dance instead of existsSync), valid inside the container via RO mounts.
|
||||
const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md';
|
||||
const SHARED_SKILLS_CONTAINER_BASE = '/app/skills';
|
||||
|
||||
const COMPOSED_HEADER = '<!-- Composed at spawn — do not edit. Edit CLAUDE.local.md for per-group content. -->';
|
||||
|
||||
/**
|
||||
* Regenerate `groups/<folder>/CLAUDE.md` from the shared base, enabled skill
|
||||
* fragments, and MCP server fragments declared in `container.json`. Creates
|
||||
* an empty `CLAUDE.local.md` if missing.
|
||||
*/
|
||||
export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
const groupDir = path.resolve(GROUPS_DIR, group.folder);
|
||||
if (!fs.existsSync(groupDir)) {
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sharedLink = path.join(groupDir, '.claude-shared.md');
|
||||
syncSymlink(sharedLink, SHARED_CLAUDE_MD_CONTAINER_PATH);
|
||||
|
||||
const fragmentsDir = path.join(groupDir, '.claude-fragments');
|
||||
if (!fs.existsSync(fragmentsDir)) {
|
||||
fs.mkdirSync(fragmentsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Desired fragment set.
|
||||
const config = readContainerConfig(group.folder);
|
||||
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
|
||||
|
||||
// Skill fragments — every skill that ships an `instructions.md`.
|
||||
// TODO (shared-source refactor): respect `container.json` skill selection.
|
||||
const skillsHostDir = path.join(process.cwd(), 'container', 'skills');
|
||||
if (fs.existsSync(skillsHostDir)) {
|
||||
for (const skillName of fs.readdirSync(skillsHostDir)) {
|
||||
const hostFragment = path.join(skillsHostDir, skillName, 'instructions.md');
|
||||
if (fs.existsSync(hostFragment)) {
|
||||
desired.set(`${skillName}.md`, {
|
||||
type: 'symlink',
|
||||
content: `${SHARED_SKILLS_CONTAINER_BASE}/${skillName}/instructions.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP server fragments — inline instructions from container.json.
|
||||
for (const [name, mcp] of Object.entries(config.mcpServers)) {
|
||||
if (mcp.instructions) {
|
||||
desired.set(`mcp-${name}.md`, {
|
||||
type: 'inline',
|
||||
content: mcp.instructions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile: drop stale, write desired.
|
||||
for (const existing of fs.readdirSync(fragmentsDir)) {
|
||||
if (!desired.has(existing)) {
|
||||
fs.unlinkSync(path.join(fragmentsDir, existing));
|
||||
}
|
||||
}
|
||||
for (const [name, frag] of desired) {
|
||||
const fragPath = path.join(fragmentsDir, name);
|
||||
if (frag.type === 'symlink') {
|
||||
syncSymlink(fragPath, frag.content);
|
||||
} else {
|
||||
writeAtomic(fragPath, frag.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Composed entry — imports only.
|
||||
const imports = ['@./.claude-shared.md'];
|
||||
for (const name of [...desired.keys()].sort()) {
|
||||
imports.push(`@./.claude-fragments/${name}`);
|
||||
}
|
||||
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
|
||||
writeAtomic(path.join(groupDir, 'CLAUDE.md'), body);
|
||||
|
||||
const localFile = path.join(groupDir, 'CLAUDE.local.md');
|
||||
if (!fs.existsSync(localFile)) {
|
||||
fs.writeFileSync(localFile, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time cutover from the `groups/global/CLAUDE.md` + `.claude-global.md`
|
||||
* pattern. Idempotent — safe to run on every host startup.
|
||||
*
|
||||
* For each group dir:
|
||||
* - remove `.claude-global.md` symlink if present
|
||||
* - rename `CLAUDE.md` → `CLAUDE.local.md` (only if `CLAUDE.local.md`
|
||||
* doesn't already exist — preserves pre-cutover content as per-group
|
||||
* memory; after the first spawn regenerates `CLAUDE.md`, this branch
|
||||
* is skipped because `CLAUDE.local.md` now exists)
|
||||
*
|
||||
* Globally:
|
||||
* - delete `groups/global/` (content already in `container/CLAUDE.md`)
|
||||
*/
|
||||
export function migrateGroupsToClaudeLocal(): void {
|
||||
if (!fs.existsSync(GROUPS_DIR)) return;
|
||||
|
||||
const actions: string[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(GROUPS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'global') continue;
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, entry.name);
|
||||
|
||||
const oldGlobalLink = path.join(groupDir, '.claude-global.md');
|
||||
try {
|
||||
fs.lstatSync(oldGlobalLink);
|
||||
fs.unlinkSync(oldGlobalLink);
|
||||
actions.push(`${entry.name}/.claude-global.md removed`);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
|
||||
const claudeMd = path.join(groupDir, 'CLAUDE.md');
|
||||
const claudeLocal = path.join(groupDir, 'CLAUDE.local.md');
|
||||
if (fs.existsSync(claudeMd) && !fs.existsSync(claudeLocal)) {
|
||||
fs.renameSync(claudeMd, claudeLocal);
|
||||
actions.push(`${entry.name}/CLAUDE.md → CLAUDE.local.md`);
|
||||
}
|
||||
}
|
||||
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
fs.rmSync(globalDir, { recursive: true, force: true });
|
||||
actions.push('groups/global/ removed');
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
log.info('Migrated groups to CLAUDE.local.md model', { actions });
|
||||
}
|
||||
}
|
||||
|
||||
function syncSymlink(linkPath: string, target: string): void {
|
||||
let currentTarget: string | null = null;
|
||||
try {
|
||||
currentTarget = fs.readlinkSync(linkPath);
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
if (currentTarget === target) return;
|
||||
try {
|
||||
fs.unlinkSync(linkPath);
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
fs.symlinkSync(target, linkPath);
|
||||
}
|
||||
|
||||
function writeAtomic(filePath: string, content: string): void {
|
||||
const tmp = `${filePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, content);
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
70
src/command-gate.ts
Normal file
70
src/command-gate.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Host-side command gate. Classifies inbound slash commands and gates
|
||||
* them before they reach the container.
|
||||
*
|
||||
* - Filtered commands: dropped silently (never reach the container)
|
||||
* - Admin commands: checked against user_roles; denied senders get a
|
||||
* "Permission denied" response written directly to messages_out
|
||||
* - Normal messages: pass through unchanged
|
||||
*/
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
|
||||
export type GateResult =
|
||||
| { action: 'pass' }
|
||||
| { action: 'filter' }
|
||||
| { action: 'deny'; command: string };
|
||||
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']);
|
||||
|
||||
/**
|
||||
* Classify a message and decide whether it should reach the container.
|
||||
* Returns 'pass' for normal messages and authorized admin commands,
|
||||
* 'filter' for silently-dropped commands, 'deny' for unauthorized
|
||||
* admin commands.
|
||||
*/
|
||||
export function gateCommand(
|
||||
content: string,
|
||||
userId: string | null,
|
||||
agentGroupId: string,
|
||||
): GateResult {
|
||||
let text: string;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
text = (parsed.text || '').trim();
|
||||
} catch {
|
||||
text = content.trim();
|
||||
}
|
||||
|
||||
if (!text.startsWith('/')) return { action: 'pass' };
|
||||
|
||||
const command = text.split(/\s/)[0].toLowerCase();
|
||||
|
||||
if (FILTERED_COMMANDS.has(command)) return { action: 'filter' };
|
||||
|
||||
if (ADMIN_COMMANDS.has(command)) {
|
||||
if (isAdmin(userId, agentGroupId)) {
|
||||
return { action: 'pass' };
|
||||
}
|
||||
return { action: 'deny', command };
|
||||
}
|
||||
|
||||
// Unknown slash commands pass through (the agent/SDK handles them)
|
||||
return { action: 'pass' };
|
||||
}
|
||||
|
||||
function isAdmin(userId: string | null, agentGroupId: string): boolean {
|
||||
if (!userId) return false;
|
||||
if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND (agent_group_id IS NULL OR agent_group_id = ?)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId, agentGroupId);
|
||||
return row != null;
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* Per-group container config, stored as a plain JSON file at
|
||||
* `groups/<folder>/container.json`. Replaces the former
|
||||
* `agent_groups.container_config` DB column.
|
||||
*
|
||||
* Shape:
|
||||
* {
|
||||
* mcpServers: { [name]: { command, args, env } }
|
||||
* packages: { apt: string[], npm: string[] }
|
||||
* imageTag?: string // set by buildAgentGroupImage on rebuild
|
||||
* additionalMounts?: Array<{hostPath, containerPath, readonly}>
|
||||
* }
|
||||
* `groups/<folder>/container.json`. Mounted read-only inside the container
|
||||
* at `/workspace/agent/container.json` — the runner reads it at startup but
|
||||
* cannot modify it. Config changes go through the self-mod approval flow.
|
||||
*
|
||||
* All fields are optional — a missing file or a partial file both resolve
|
||||
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
|
||||
@@ -25,6 +18,10 @@ export interface McpServerConfig {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
// Optional always-in-context guidance. When set, the host writes the
|
||||
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
|
||||
// into the composed CLAUDE.md.
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export interface AdditionalMountConfig {
|
||||
@@ -38,6 +35,18 @@ export interface ContainerConfig {
|
||||
packages: { apt: string[]; npm: string[] };
|
||||
imageTag?: string;
|
||||
additionalMounts: AdditionalMountConfig[];
|
||||
/** Which skills to enable — array of skill names or "all" (default). */
|
||||
skills: string[] | 'all';
|
||||
/** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */
|
||||
provider?: string;
|
||||
/** Agent group display name (used in transcript archiving). */
|
||||
groupName?: string;
|
||||
/** Assistant display name (used in system prompt / responses). */
|
||||
assistantName?: string;
|
||||
/** Agent group ID — set by the host, read by the runner. */
|
||||
agentGroupId?: string;
|
||||
/** Max messages per prompt. Falls back to code default if unset. */
|
||||
maxMessagesPerPrompt?: number;
|
||||
}
|
||||
|
||||
function emptyConfig(): ContainerConfig {
|
||||
@@ -45,6 +54,7 @@ function emptyConfig(): ContainerConfig {
|
||||
mcpServers: {},
|
||||
packages: { apt: [], npm: [] },
|
||||
additionalMounts: [],
|
||||
skills: 'all',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +81,12 @@ export function readContainerConfig(folder: string): ContainerConfig {
|
||||
},
|
||||
imageTag: raw.imageTag,
|
||||
additionalMounts: raw.additionalMounts ?? [],
|
||||
skills: raw.skills ?? 'all',
|
||||
provider: raw.provider,
|
||||
groupName: raw.groupName,
|
||||
assistantName: raw.assistantName,
|
||||
agentGroupId: raw.agentGroupId,
|
||||
maxMessagesPerPrompt: raw.maxMessagesPerPrompt,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[container-config] failed to parse ${p}: ${String(err)}`);
|
||||
|
||||
@@ -9,9 +9,10 @@ import path from 'path';
|
||||
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, MAX_MESSAGES_PER_PROMPT, ONECLI_URL, TIMEZONE } from './config.js';
|
||||
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js';
|
||||
import { readContainerConfig, writeContainerConfig } from './container-config.js';
|
||||
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { initGroupFilesystem } from './group-init.js';
|
||||
@@ -91,17 +92,25 @@ async function spawnContainer(session: Session): Promise<void> {
|
||||
}
|
||||
writeSessionRouting(agentGroup.id, session.id);
|
||||
|
||||
// Read container config once — threaded through provider resolution,
|
||||
// buildMounts, and buildContainerArgs so we don't re-read the file.
|
||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
||||
|
||||
// Ensure container.json has the agent group identity fields the runner needs.
|
||||
// Written at spawn time so the runner can read them from the RO mount.
|
||||
ensureRuntimeFields(containerConfig, agentGroup);
|
||||
|
||||
// Resolve the effective provider + any host-side contribution it declares
|
||||
// (extra mounts, env passthrough). Computed once and threaded through both
|
||||
// buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
|
||||
const { provider, contribution } = resolveProviderContribution(session, agentGroup);
|
||||
const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
|
||||
|
||||
const mounts = buildMounts(agentGroup, session, contribution);
|
||||
const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
|
||||
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
|
||||
// OneCLI agent identifier is always the agent group id — stable across
|
||||
// sessions and reversible via getAgentGroup() for approval routing.
|
||||
const agentIdentifier = agentGroup.id;
|
||||
const args = await buildContainerArgs(mounts, containerName, agentGroup, provider, contribution, agentIdentifier);
|
||||
const args = await buildContainerArgs(mounts, containerName, agentGroup, containerConfig, provider, contribution, agentIdentifier);
|
||||
|
||||
log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName });
|
||||
|
||||
@@ -156,8 +165,9 @@ export function killContainer(sessionId: string, reason: string): void {
|
||||
function resolveProviderContribution(
|
||||
session: Session,
|
||||
agentGroup: AgentGroup,
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
): { provider: string; contribution: ProviderContainerContribution } {
|
||||
const provider = (session.agent_provider || agentGroup.agent_provider || 'claude').toLowerCase();
|
||||
const provider = (containerConfig.provider || 'claude').toLowerCase();
|
||||
const fn = getProviderContainerConfig(provider);
|
||||
const contribution = fn
|
||||
? fn({
|
||||
@@ -172,15 +182,24 @@ function resolveProviderContribution(
|
||||
function buildMounts(
|
||||
agentGroup: AgentGroup,
|
||||
session: Session,
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
providerContribution: ProviderContainerContribution,
|
||||
): VolumeMount[] {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Per-group filesystem state lives forever after first creation. Init is
|
||||
// idempotent: it only writes paths that don't already exist, so this call
|
||||
// is a no-op for groups that have spawned before. Pulling in upstream
|
||||
// built-in skill or agent-runner source updates is an explicit operation
|
||||
// (host-mediated tools), not something the spawn path does silently.
|
||||
// is a no-op for groups that have spawned before.
|
||||
initGroupFilesystem(agentGroup);
|
||||
|
||||
// Sync skill symlinks based on container.json selection before mounting.
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
|
||||
syncSkillSymlinks(claudeDir, containerConfig);
|
||||
|
||||
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
|
||||
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
|
||||
composeGroupClaudeMd(agentGroup);
|
||||
|
||||
const mounts: VolumeMount[] = [];
|
||||
const sessDir = sessionDir(agentGroup.id, session.id);
|
||||
const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
|
||||
@@ -188,28 +207,44 @@ function buildMounts(
|
||||
// Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/)
|
||||
mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false });
|
||||
|
||||
// Agent group folder at /workspace/agent
|
||||
// Agent group folder at /workspace/agent (RW for working files + CLAUDE.md)
|
||||
mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false });
|
||||
|
||||
// Global memory directory — always read-only. Edits to global config
|
||||
// happen through the approval flow, not by handing one workspace RW.
|
||||
// container.json — nested RO mount on top of RW group dir so the agent
|
||||
// can read its config but cannot modify it.
|
||||
const containerJsonPath = path.join(groupDir, 'container.json');
|
||||
if (fs.existsSync(containerJsonPath)) {
|
||||
mounts.push({ hostPath: containerJsonPath, containerPath: '/workspace/agent/container.json', readonly: true });
|
||||
}
|
||||
|
||||
// Global memory directory — always read-only.
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
|
||||
}
|
||||
|
||||
// Shared CLAUDE.md — read-only, imported by the composed entry point via
|
||||
// the `.claude-shared.md` symlink inside the group dir.
|
||||
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
|
||||
if (fs.existsSync(sharedClaudeMd)) {
|
||||
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
|
||||
}
|
||||
|
||||
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
|
||||
// skills — initialized once at group creation, persistent thereafter)
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
|
||||
// skill symlinks)
|
||||
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
|
||||
|
||||
// Per-group agent-runner source at /app/src (initialized once at group
|
||||
// creation, persistent thereafter — agents can modify their runner)
|
||||
const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src');
|
||||
mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false });
|
||||
// Shared agent-runner source — read-only, same code for all groups.
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
mounts.push({ hostPath: agentRunnerSrc, containerPath: '/app/src', readonly: true });
|
||||
|
||||
// Additional mounts from container config (groups/<folder>/container.json)
|
||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
||||
// Shared skills — read-only, symlinks in .claude-shared/skills/ point here.
|
||||
const skillsSrc = path.join(projectRoot, 'container', 'skills');
|
||||
if (fs.existsSync(skillsSrc)) {
|
||||
mounts.push({ hostPath: skillsSrc, containerPath: '/app/skills', readonly: true });
|
||||
}
|
||||
|
||||
// Additional mounts from container config
|
||||
if (containerConfig.additionalMounts && containerConfig.additionalMounts.length > 0) {
|
||||
const validated = validateAdditionalMounts(containerConfig.additionalMounts, agentGroup.name);
|
||||
mounts.push(...validated);
|
||||
@@ -223,32 +258,113 @@ function buildMounts(
|
||||
return mounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync skill symlinks in .claude-shared/skills/ to match the container.json
|
||||
* selection. Each symlink points to a container path (/app/skills/<name>)
|
||||
* so it's dangling on the host but valid inside the container.
|
||||
*/
|
||||
function syncSkillSymlinks(
|
||||
claudeDir: string,
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
): void {
|
||||
const skillsDir = path.join(claudeDir, 'skills');
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Determine desired skill set
|
||||
const projectRoot = process.cwd();
|
||||
const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
|
||||
let desired: string[];
|
||||
if (containerConfig.skills === 'all') {
|
||||
// Recompute from shared dir — newly-added upstream skills appear automatically
|
||||
desired = fs.existsSync(sharedSkillsDir)
|
||||
? fs.readdirSync(sharedSkillsDir).filter((e) => {
|
||||
try {
|
||||
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
} else {
|
||||
desired = containerConfig.skills;
|
||||
}
|
||||
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Remove symlinks not in the desired set
|
||||
for (const entry of fs.readdirSync(skillsDir)) {
|
||||
const entryPath = path.join(skillsDir, entry);
|
||||
let isSymlink = false;
|
||||
try {
|
||||
isSymlink = fs.lstatSync(entryPath).isSymbolicLink();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (isSymlink && !desiredSet.has(entry)) {
|
||||
fs.unlinkSync(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlinks for desired skills (container path targets)
|
||||
for (const skill of desired) {
|
||||
const linkPath = path.join(skillsDir, skill);
|
||||
let exists = false;
|
||||
try {
|
||||
fs.lstatSync(linkPath);
|
||||
exists = true;
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
if (!exists) {
|
||||
fs.symlinkSync(`/app/skills/${skill}`, linkPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure container.json has the runtime identity fields the runner needs.
|
||||
* Written at spawn time so they're always current even if the DB values
|
||||
* change (e.g. group rename). Only writes if values differ to avoid
|
||||
* unnecessary file churn.
|
||||
*/
|
||||
function ensureRuntimeFields(
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
agentGroup: AgentGroup,
|
||||
): void {
|
||||
let dirty = false;
|
||||
if (containerConfig.agentGroupId !== agentGroup.id) {
|
||||
containerConfig.agentGroupId = agentGroup.id;
|
||||
dirty = true;
|
||||
}
|
||||
if (containerConfig.groupName !== agentGroup.name) {
|
||||
containerConfig.groupName = agentGroup.name;
|
||||
dirty = true;
|
||||
}
|
||||
if (containerConfig.assistantName !== agentGroup.name) {
|
||||
containerConfig.assistantName = agentGroup.name;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
writeContainerConfig(agentGroup.folder, containerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
agentGroup: AgentGroup,
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
provider: string,
|
||||
providerContribution: ProviderContainerContribution,
|
||||
agentIdentifier?: string,
|
||||
): Promise<string[]> {
|
||||
const args: string[] = ['run', '--rm', '--name', containerName];
|
||||
|
||||
// Environment
|
||||
// Environment — only vars read by code we don't own.
|
||||
// Everything NanoClaw-specific is in container.json (read by runner at startup).
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
args.push('-e', `AGENT_PROVIDER=${provider}`);
|
||||
// Two-DB split: container reads inbound.db, writes outbound.db
|
||||
args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db');
|
||||
args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.db');
|
||||
args.push('-e', 'SESSION_HEARTBEAT_PATH=/workspace/.heartbeat');
|
||||
|
||||
if (agentGroup.name) {
|
||||
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
|
||||
}
|
||||
args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`);
|
||||
args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`);
|
||||
// Cap on how many pending messages reach one prompt. Accumulated context
|
||||
// (trigger=0 rows) rides along with wake-eligible rows up to this cap.
|
||||
args.push('-e', `NANOCLAW_MAX_MESSAGES_PER_PROMPT=${MAX_MESSAGES_PER_PROMPT}`);
|
||||
|
||||
// Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY).
|
||||
if (providerContribution.env) {
|
||||
@@ -257,39 +373,8 @@ async function buildContainerArgs(
|
||||
}
|
||||
}
|
||||
|
||||
// Users allowed to run admin commands (e.g. /clear) inside this container.
|
||||
// Computed at wake time: owners + global admins + admins scoped to this
|
||||
// agent group. Role changes take effect on next container spawn.
|
||||
//
|
||||
// SQL inlined to keep core independent of the permissions module — we
|
||||
// guard on the `user_roles` table directly. If the permissions module
|
||||
// isn't installed, the table doesn't exist and the set stays empty; the
|
||||
// formatter treats an empty admin set as permissionless mode (every
|
||||
// sender is admin).
|
||||
const adminUserIds = new Set<string>();
|
||||
if (hasTable(getDb(), 'user_roles')) {
|
||||
const db = getDb();
|
||||
const owners = db
|
||||
.prepare("SELECT user_id FROM user_roles WHERE role = 'owner' AND agent_group_id IS NULL")
|
||||
.all() as Array<{ user_id: string }>;
|
||||
const globalAdmins = db
|
||||
.prepare("SELECT user_id FROM user_roles WHERE role = 'admin' AND agent_group_id IS NULL")
|
||||
.all() as Array<{ user_id: string }>;
|
||||
const scopedAdmins = db
|
||||
.prepare("SELECT user_id FROM user_roles WHERE role = 'admin' AND agent_group_id = ?")
|
||||
.all(agentGroup.id) as Array<{ user_id: string }>;
|
||||
for (const r of owners) adminUserIds.add(r.user_id);
|
||||
for (const r of globalAdmins) adminUserIds.add(r.user_id);
|
||||
for (const r of scopedAdmins) adminUserIds.add(r.user_id);
|
||||
}
|
||||
if (adminUserIds.size > 0) {
|
||||
args.push('-e', `NANOCLAW_ADMIN_USER_IDS=${Array.from(adminUserIds).join(',')}`);
|
||||
}
|
||||
|
||||
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
|
||||
// are routed through the agent vault for credential injection.
|
||||
// Must ensureAgent first for non-admin groups, otherwise applyContainerConfig
|
||||
// rejects the unknown agent identifier and returns false.
|
||||
try {
|
||||
if (agentIdentifier) {
|
||||
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
|
||||
@@ -324,16 +409,7 @@ async function buildContainerArgs(
|
||||
}
|
||||
}
|
||||
|
||||
// Pass additional MCP servers from container config (groups/<folder>/container.json)
|
||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
||||
if (containerConfig.mcpServers && Object.keys(containerConfig.mcpServers).length > 0) {
|
||||
args.push('-e', `NANOCLAW_MCP_SERVERS=${JSON.stringify(containerConfig.mcpServers)}`);
|
||||
}
|
||||
|
||||
// Override entrypoint: run v2 entry point directly via Bun (no tsc, no stdin).
|
||||
// The image's ENTRYPOINT (tini → entrypoint.sh) handles the stdin-piped
|
||||
// invocation path; the host-spawned sessions don't need stdin because all
|
||||
// IO flows through the mounted session DBs.
|
||||
args.push('--entrypoint', 'bash');
|
||||
|
||||
// Use per-agent-group image if one has been built, otherwise base image
|
||||
|
||||
@@ -6,18 +6,6 @@ import { initContainerConfig } from './container-config.js';
|
||||
import { log } from './log.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
// Container path where groups/global is mounted. The symlink we drop
|
||||
// into each group's dir resolves to this target inside the container.
|
||||
// It's a dangling symlink on the host — that's fine, host tools don't
|
||||
// follow it and the container mount makes it valid at read time.
|
||||
const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md';
|
||||
|
||||
// Symlink name inside the group's dir. Claude Code's @-import only
|
||||
// follows paths inside cwd, so we can't reference /workspace/global
|
||||
// directly — we symlink into the group dir and import the symlink.
|
||||
export const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md';
|
||||
export const GLOBAL_CLAUDE_IMPORT = `@./${GLOBAL_MEMORY_LINK_NAME}`;
|
||||
|
||||
const DEFAULT_SETTINGS_JSON =
|
||||
JSON.stringify(
|
||||
{
|
||||
@@ -36,13 +24,17 @@ const DEFAULT_SETTINGS_JSON =
|
||||
* every step is gated on the target not already existing, so re-running on
|
||||
* an already-initialized group is a no-op.
|
||||
*
|
||||
* Called once per group lifetime: at creation, or defensively from
|
||||
* `buildMounts()` for groups that pre-date this code path. After init, the
|
||||
* host never overwrites any of these paths automatically — agents own them.
|
||||
* To pull in upstream changes, use the host-mediated reset/refresh tools.
|
||||
* Called once per group lifetime at creation, or defensively from
|
||||
* `buildMounts()` for groups that pre-date this code path.
|
||||
*
|
||||
* Source code and skills are shared RO mounts — not copied per-group.
|
||||
* Skill symlinks are synced at spawn time by container-runner.ts.
|
||||
*
|
||||
* The composed `CLAUDE.md` is NOT written here — it's regenerated on every
|
||||
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
|
||||
* per-group instructions (if provided) seed `CLAUDE.local.md`.
|
||||
*/
|
||||
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
|
||||
const projectRoot = process.cwd();
|
||||
const initialized: string[] = [];
|
||||
|
||||
// 1. groups/<folder>/ — group memory + working dir
|
||||
@@ -52,29 +44,13 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
initialized.push('groupDir');
|
||||
}
|
||||
|
||||
// groups/<folder>/.claude-global.md — symlink into the group dir so
|
||||
// Claude Code's @-import can follow it. Uses lstat to avoid tripping
|
||||
// existsSync on a dangling symlink (target only resolves inside the
|
||||
// container).
|
||||
const globalLinkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME);
|
||||
let linkExists = false;
|
||||
try {
|
||||
fs.lstatSync(globalLinkPath);
|
||||
linkExists = true;
|
||||
} catch {
|
||||
/* missing — recreate */
|
||||
}
|
||||
if (!linkExists) {
|
||||
fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, globalLinkPath);
|
||||
initialized.push('.claude-global.md');
|
||||
}
|
||||
|
||||
// groups/<folder>/CLAUDE.md — written once, then owned by the group
|
||||
const claudeMdFile = path.join(groupDir, 'CLAUDE.md');
|
||||
if (!fs.existsSync(claudeMdFile)) {
|
||||
const body = [GLOBAL_CLAUDE_IMPORT, '', opts?.instructions ?? `# ${group.name}`].join('\n') + '\n';
|
||||
fs.writeFileSync(claudeMdFile, body);
|
||||
initialized.push('CLAUDE.md');
|
||||
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
|
||||
// Claude Code. Seeded with caller-provided instructions on first creation.
|
||||
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
|
||||
if (!fs.existsSync(claudeLocalFile)) {
|
||||
const body = opts?.instructions ? opts.instructions + '\n' : '';
|
||||
fs.writeFileSync(claudeLocalFile, body);
|
||||
initialized.push('CLAUDE.local.md');
|
||||
}
|
||||
|
||||
// groups/<folder>/container.json — empty container config, replaces the
|
||||
@@ -97,23 +73,12 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
initialized.push('settings.json');
|
||||
}
|
||||
|
||||
// Skills directory — created empty here; symlinks are synced at spawn
|
||||
// time by container-runner.ts based on container.json skills selection.
|
||||
const skillsDst = path.join(claudeDir, 'skills');
|
||||
if (!fs.existsSync(skillsDst)) {
|
||||
const skillsSrc = path.join(projectRoot, 'container', 'skills');
|
||||
if (fs.existsSync(skillsSrc)) {
|
||||
fs.cpSync(skillsSrc, skillsDst, { recursive: true });
|
||||
initialized.push('skills/');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. data/v2-sessions/<id>/agent-runner-src/ — per-group source copy
|
||||
const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', group.id, 'agent-runner-src');
|
||||
if (!fs.existsSync(groupRunnerDir)) {
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
if (fs.existsSync(agentRunnerSrc)) {
|
||||
fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true });
|
||||
initialized.push('agent-runner-src/');
|
||||
}
|
||||
fs.mkdirSync(skillsDst, { recursive: true });
|
||||
initialized.push('skills/');
|
||||
}
|
||||
|
||||
if (initialized.length > 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
|
||||
import { initDb } from './db/connection.js';
|
||||
import { runMigrations } from './db/migrations/index.js';
|
||||
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
|
||||
@@ -63,6 +64,9 @@ async function main(): Promise<void> {
|
||||
runMigrations(db);
|
||||
log.info('Central DB ready', { path: dbPath });
|
||||
|
||||
// 1b. One-time filesystem cutover — idempotent, no-op after first run.
|
||||
migrateGroupsToClaudeLocal();
|
||||
|
||||
// 2. Container runtime
|
||||
ensureContainerRuntimeRunning();
|
||||
cleanupOrphans();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* for policy refusals.
|
||||
*/
|
||||
import { getChannelAdapter } from './channels/channel-registry.js';
|
||||
import { gateCommand } from './command-gate.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
import {
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
import { findSessionForAgent } from './db/sessions.js';
|
||||
import { startTypingRefresh } from './modules/typing/index.js';
|
||||
import { log } from './log.js';
|
||||
import { resolveSession, writeSessionMessage } from './session-manager.js';
|
||||
import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js';
|
||||
import { wakeContainer } from './container-runner.js';
|
||||
import { getSession } from './db/sessions.js';
|
||||
import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js';
|
||||
@@ -398,6 +399,29 @@ async function deliverToAgent(
|
||||
threadId: event.threadId,
|
||||
};
|
||||
|
||||
// Command gate: classify slash commands before they reach the container.
|
||||
// Filtered commands are dropped silently. Denied admin commands get a
|
||||
// permission-denied response written directly to messages_out.
|
||||
if (event.message.kind === 'chat' || event.message.kind === 'chat-sdk') {
|
||||
const gate = gateCommand(event.message.content, userId, agent.agent_group_id);
|
||||
if (gate.action === 'filter') {
|
||||
log.debug('Filtered command dropped by gate', { agentGroupId: agent.agent_group_id });
|
||||
return;
|
||||
}
|
||||
if (gate.action === 'deny') {
|
||||
writeOutboundDirect(session.agent_group_id, session.id, {
|
||||
id: `deny-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
platformId: deliveryAddr.platformId,
|
||||
channelType: deliveryAddr.channelType,
|
||||
threadId: deliveryAddr.threadId,
|
||||
content: JSON.stringify({ text: `Permission denied: ${gate.command} requires admin access.` }),
|
||||
});
|
||||
log.info('Admin command denied by gate', { command: gate.command, userId, agentGroupId: agent.agent_group_id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: messageIdForAgent(event.message.id, agent.agent_group_id),
|
||||
kind: event.message.kind,
|
||||
|
||||
@@ -279,6 +279,34 @@ export function openOutboundDb(agentGroupId: string, sessionId: string): Databas
|
||||
return openOutboundDbRaw(outboundDbPath(agentGroupId, sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a message directly to a session's outbound DB so the host delivery
|
||||
* loop picks it up. Used by the command gate to send denial responses
|
||||
* without waking a container.
|
||||
*/
|
||||
export function writeOutboundDirect(
|
||||
agentGroupId: string,
|
||||
sessionId: string,
|
||||
message: {
|
||||
id: string;
|
||||
kind: string;
|
||||
platformId: string | null;
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
content: string;
|
||||
},
|
||||
): void {
|
||||
const db = openOutboundDb(agentGroupId, sessionId);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content)
|
||||
VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), datetime('now'), ?, ?, ?, ?, ?)`,
|
||||
).run(message.id, message.kind, message.platformId, message.channelType, message.threadId, message.content);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use openInboundDb / openOutboundDb instead.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user