diff --git a/CLAUDE.md b/CLAUDE.md index e941490..1cf7e6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,10 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge | | `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache | | `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) | -| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations | +| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) | +| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup | +| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers | +| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations | | `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch | | `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) | | `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations | @@ -93,7 +96,7 @@ ncl help | Resource | Verbs | What it is | |----------|-------|------------| -| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | +| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) | | messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | | wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | | users | list, get, create, update | Platform identities (`:`) | @@ -120,10 +123,32 @@ Each `/add-` skill is idempotent: `git fetch origin ` → copy mod One tier of agent self-modification today: -1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`. +1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`. A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented. +## Container Config + +Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups//container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools. + +**`cli_scope`** — controls what the agent can do with `ncl` from inside the container: + +| Value | Behavior | +|-------|----------| +| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. | +| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. | +| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. | + +Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion). + +## Container Restart + +`ncl groups restart --id [--rebuild] [--message ]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted. + +The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns. + +Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`). + ## Secrets / Credentials / OneCLI API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts index d86c601..c835368 100644 --- a/container/agent-runner/src/cli/ncl.ts +++ b/container/agent-runner/src/cli/ncl.ts @@ -165,12 +165,9 @@ function parseArgv(argv: string[]): { process.exit(2); } - const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; - - // Third positional is the target ID - if (positional.length >= 3) { - args.id = positional[2]; - } + // Join all positionals with dashes. The dispatcher trims the last + // segment as a target ID if the full name isn't a registered command. + const command = positional.join('-'); return { command, args, json }; } diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 871e43a..51a82d7 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -196,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { platform_id TEXT, channel_type TEXT, thread_id TEXT, - content TEXT NOT NULL + content TEXT NOT NULL, + on_wake INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE delivered ( message_out_id TEXT PRIMARY KEY, diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 88906ed..d3a1a33 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -49,7 +49,7 @@ function getMaxMessagesPerPrompt(): number { * sees the prior context it missed. Host's countDueMessages gates waking on * trigger=1 separately (see src/db/session-db.ts). */ -export function getPendingMessages(): MessageInRow[] { +export function getPendingMessages(isFirstPoll = false): MessageInRow[] { const inbound = openInboundDb(); const outbound = getOutboundDb(); @@ -59,10 +59,11 @@ export function getPendingMessages(): MessageInRow[] { `SELECT * FROM messages_in WHERE status = 'pending' AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) + AND (on_wake = 0 OR ?1 = 1) ORDER BY seq DESC - LIMIT ?`, + LIMIT ?2`, ) - .all(getMaxMessagesPerPrompt()) as MessageInRow[]; + .all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[]; if (pending.length === 0) return []; diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index 9dee60f..6a4f72e 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -1,49 +1,51 @@ ## Admin CLI (`ncl`) -The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. +The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration. ### Usage ``` -ncl [] [--flags] +ncl [--flags] ncl help ncl help ``` +### Scope + +Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them. + ### Resources +Run `ncl help` for the full list. Common resources: + | Resource | Verbs | What it is | |----------|-------|------------| -| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | -| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | -| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | -| users | list, get, create, update | Platform identities (`:`) | -| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) | -| members | list, add, remove | Unprivileged access gate for an agent group | -| destinations | list, add, remove | Where an agent group can send messages | +| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) | | sessions | list, get | Active sessions (read-only) | -| user-dms | list | Cold-DM cache (read-only) | -| dropped-messages | list | Messages from unregistered senders (read-only) | -| approvals | list, get | Pending approval requests (read-only) | +| destinations | list, add, remove | Where an agent group can send messages | +| members | list, add, remove | Unprivileged access gate for an agent group | + +Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals. ### When to use -- **Looking up your own config** — `ncl groups get ` to see your agent group settings. -- **Finding who you're wired to** — `ncl wirings list` to see which messaging groups route to which agent groups. -- **Checking user roles** — `ncl roles list` to see who is an owner/admin. -- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `ncl` rather than guessing. +- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config. +- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`). +- **Checking who's in your group** — `ncl members list`. +- **Seeing your destinations** — `ncl destinations list`. +- **Answering questions about the system** — query `ncl` rather than guessing. ### Access rules -Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it. +Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. ### Approval flow -Write commands (create, update, delete, grant, revoke, add, remove) require admin approval. Here's what happens: +Write commands require admin approval. Here's what happens: -1. You run the command (e.g. `ncl groups create --name "Research" --folder research`). +1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`). 2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet. -3. An admin or owner gets a notification (on the same channel when possible) showing exactly what you requested, with approve/reject options. +3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options. 4. Once the admin responds: - **Approved:** the command executes and the result is delivered back to you as a system message in this conversation. - **Rejected:** you get a system message saying the request was rejected. @@ -54,25 +56,24 @@ You don't need to poll or retry — the result arrives automatically. ```bash # Read commands (no approval needed) -ncl groups list -ncl groups get abc123 -ncl wirings list --messaging-group-id mg_xyz -ncl roles list -ncl wirings help +ncl groups get +ncl groups config get +ncl sessions list +ncl destinations list +ncl members list # Write commands (approval required) -ncl groups create --name "Research" --folder research -ncl groups update abc123 --name "Research v2" -ncl roles grant --user telegram:jane --role admin -ncl roles grant --user discord:bob --role admin --group abc123 -ncl members add --user-id telegram:jane --agent-group-id abc123 -ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz +ncl groups restart +ncl groups restart --rebuild --message "Config updated." +ncl groups config update --model claude-sonnet-4-5-20250514 +ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]' +ncl groups config add-package --npm some-package +ncl members add --user telegram:jane ``` ### Tips -- Use `ncl help` to see all available fields, types, enums, and which fields are required or updatable. +- Use `ncl help` to see all available fields, types, enums, and which fields are auto-filled. - Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. -- `list` supports filtering by any non-auto column (e.g. `ncl wirings list --messaging-group-id mg_xyz`). Default limit is 200 rows; override with `--limit N`. -- For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. +- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`. - Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result. diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 82f9f75..29b769b 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -14,13 +14,18 @@ afterEach(() => { closeSessionDb(); }); -function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) { +function insertMessage( + id: string, + kind: string, + content: object, + opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 }, +) { getInboundDb() .prepare( - `INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content) - VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`, + `INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`, ) - .run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content)); + .run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content)); } describe('formatter', () => { @@ -131,6 +136,58 @@ describe('accumulate gate (trigger column)', () => { }); }); +describe('on_wake filtering', () => { + it('first poll returns on_wake=1 messages', () => { + insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(true); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('m1'); + }); + + it('subsequent polls skip on_wake=1 messages', () => { + insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(false); + expect(messages).toHaveLength(0); + }); + + it('normal messages returned regardless of isFirstPoll', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'hello' }); + expect(getPendingMessages(true)).toHaveLength(1); + + // Reset: mark completed so we can re-test with a fresh message + markCompleted(['m1']); + insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' }); + expect(getPendingMessages(false)).toHaveLength(1); + }); + + it('mixed batch: first poll returns both normal and on_wake messages', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' }); + insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(true); + expect(messages).toHaveLength(2); + expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']); + }); + + it('mixed batch: subsequent poll returns only normal messages', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' }); + insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(false); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('m1'); + }); + + it('on_wake defaults to 0 for inserts without explicit value', () => { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`, + ) + .run(); + // Should be returned even on non-first poll (on_wake=0) + expect(getPendingMessages(false)).toHaveLength(1); + }); +}); + describe('routing', () => { it('should extract routing from messages', () => { getInboundDb() diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e0ac722..bbf45be 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -67,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearStaleProcessingAcks(); let pollCount = 0; + let isFirstPoll = true; while (true) { // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) - const messages = getPendingMessages().filter((m) => m.kind !== 'system'); + const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system'); + isFirstPoll = false; pollCount++; // Periodic heartbeat so we know the loop is alive diff --git a/docs/db-central.md b/docs/db-central.md index 8268acf..75c27f3 100644 --- a/docs/db-central.md +++ b/docs/db-central.md @@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com ### 1.1 `agent_groups` -Agent workspaces. Each maps 1:1 to a `groups//` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB. +Agent workspaces. Each maps 1:1 to a `groups//` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read. ```sql CREATE TABLE agent_groups ( @@ -294,6 +294,32 @@ CREATE TABLE schema_version ( ); ``` +### 1.15 `container_configs` + +Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups//container.json` at spawn time. + +```sql +CREATE TABLE container_configs ( + agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE, + provider TEXT, + model TEXT, + effort TEXT, + image_tag TEXT, + assistant_name TEXT, + max_messages_per_prompt INTEGER, + skills TEXT NOT NULL DEFAULT '"all"', + mcp_servers TEXT NOT NULL DEFAULT '{}', + packages_apt TEXT NOT NULL DEFAULT '[]', + packages_npm TEXT NOT NULL DEFAULT '[]', + additional_mounts TEXT NOT NULL DEFAULT '[]', + cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global + updated_at TEXT NOT NULL +); +``` + +- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` +- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts` + --- ## 2. Migration system @@ -313,6 +339,8 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig | 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) | | 008 | `008-dropped-messages.ts` | `unregistered_senders` | | 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table | +| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config | +| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` | Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development. diff --git a/docs/db-session.md b/docs/db-session.md index 9370d90..2b9fd23 100644 --- a/docs/db-session.md +++ b/docs/db-session.md @@ -33,19 +33,22 @@ Every message landing in the session: user chat, scheduled task, recurring task, ```sql CREATE TABLE messages_in ( - id TEXT PRIMARY KEY, - seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3 - kind TEXT NOT NULL, - timestamp TEXT NOT NULL, - status TEXT DEFAULT 'pending', -- pending|completed|failed|paused - process_after TEXT, - recurrence TEXT, -- cron expr for recurring - series_id TEXT, -- groups occurrences of a recurring task - tries INTEGER DEFAULT 0, - platform_id TEXT, - channel_type TEXT, - thread_id TEXT, - content TEXT NOT NULL -- JSON; shape depends on kind + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3 + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- pending|completed|failed|paused + process_after TEXT, + recurrence TEXT, -- cron expr for recurring + series_id TEXT, -- groups occurrences of a recurring task + tries INTEGER DEFAULT 0, + trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL, -- JSON; shape depends on kind + source_session_id TEXT, -- agent-to-agent return path + on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll ); CREATE INDEX idx_messages_in_series ON messages_in(series_id); ``` diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 61a17d6..461e407 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -47,6 +47,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio import { addMember } from '../src/modules/permissions/db/agent-group-members.js'; import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; +import { updateContainerConfigScalars } from '../src/db/container-configs.js'; import { initGroupFilesystem } from '../src/group-init.js'; import { namespacedPlatformId } from '../src/platform-id.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; @@ -231,6 +232,8 @@ async function main(): Promise { granted_at: now, }); } + // Owner's agent group gets global CLI access + updateContainerConfigScalars(ag.id, { cli_scope: 'global' }); } else if (args.role === 'admin') { const alreadyAdmin = existingRoles.some( (r) => r.role === 'admin' && r.agent_group_id === ag.id, diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts new file mode 100644 index 0000000..5551c90 --- /dev/null +++ b/src/backfill-container-configs.ts @@ -0,0 +1,78 @@ +/** + * One-time backfill: seed `container_configs` rows from existing + * `groups//container.json` files and `agent_groups.agent_provider`. + * + * Runs after migrations, before channel adapters start. Idempotent — skips + * groups that already have a config row. + */ +import fs from 'fs'; +import path from 'path'; + +import { GROUPS_DIR } from './config.js'; +import type { McpServerConfig, AdditionalMountConfig } from './container-config.js'; +import { getAllAgentGroups } from './db/agent-groups.js'; +import { getContainerConfig, createContainerConfig } from './db/container-configs.js'; +import { log } from './log.js'; +import type { ContainerConfigRow } from './types.js'; + +interface LegacyContainerJson { + mcpServers?: Record; + packages?: { apt?: string[]; npm?: string[] }; + imageTag?: string; + additionalMounts?: AdditionalMountConfig[]; + skills?: string[] | 'all'; + provider?: string; + assistantName?: string; + maxMessagesPerPrompt?: number; +} + +export function backfillContainerConfigs(): void { + const groups = getAllAgentGroups(); + let backfilled = 0; + + for (const group of groups) { + // Skip if already has a config row + if (getContainerConfig(group.id)) continue; + + // Read legacy container.json from disk + const filePath = path.join(GROUPS_DIR, group.folder, 'container.json'); + let legacy: LegacyContainerJson = {}; + if (fs.existsSync(filePath)) { + try { + legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson; + } catch (err) { + log.warn('Backfill: failed to parse container.json, using defaults', { + folder: group.folder, + err: String(err), + }); + } + } + + // DB agent_provider wins over file provider (matches old cascade) + const provider = group.agent_provider || legacy.provider || null; + + const row: ContainerConfigRow = { + agent_group_id: group.id, + provider, + model: null, + effort: null, + image_tag: legacy.imageTag ?? null, + assistant_name: legacy.assistantName ?? null, + max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null, + skills: JSON.stringify(legacy.skills ?? 'all'), + mcp_servers: JSON.stringify(legacy.mcpServers ?? {}), + packages_apt: JSON.stringify(legacy.packages?.apt ?? []), + packages_npm: JSON.stringify(legacy.packages?.npm ?? []), + additional_mounts: JSON.stringify(legacy.additionalMounts ?? []), + cli_scope: 'group', + updated_at: new Date().toISOString(), + }; + + createContainerConfig(row); + backfilled++; + } + + if (backfilled > 0) { + log.info('Backfilled container_configs from disk', { count: backfilled }); + } +} diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts index c0519e4..285f79a 100644 --- a/src/claude-md-compose.ts +++ b/src/claude-md-compose.ts @@ -18,7 +18,8 @@ import fs from 'fs'; import path from 'path'; import { GROUPS_DIR } from './config.js'; -import { readContainerConfig } from './container-config.js'; +import type { McpServerConfig } from './container-config.js'; +import { getContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; @@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void { } // Desired fragment set. - const config = readContainerConfig(group.folder); + const configRow = getContainerConfig(group.id); + const mcpServers: Record = configRow + ? (JSON.parse(configRow.mcp_servers) as Record) + : {}; const desired = new Map(); // Skill fragments — every skill that ships an `instructions.md`. @@ -75,13 +79,15 @@ export function composeGroupClaudeMd(group: AgentGroup): void { // Built-in module fragments — every MCP tool source file that ships a // sibling `.instructions.md`. These describe how the agent should // use that module's MCP tools (schedule_task, install_packages, etc.). - // Always included — these are built-in, not toggleable. + // Skip cli.instructions.md when cli_scope is disabled. + const cliDisabled = configRow?.cli_scope === 'disabled'; const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH); if (fs.existsSync(mcpToolsHostDir)) { for (const entry of fs.readdirSync(mcpToolsHostDir)) { const match = entry.match(/^(.+)\.instructions\.md$/); if (!match) continue; const moduleName = match[1]; + if (moduleName === 'cli' && cliDisabled) continue; desired.set(`module-${moduleName}.md`, { type: 'symlink', content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`, @@ -91,7 +97,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void { // MCP server fragments — inline instructions from container.json for // user-added external MCP servers. - for (const [name, mcp] of Object.entries(config.mcpServers)) { + for (const [name, mcp] of Object.entries(mcpServers)) { if (mcp.instructions) { desired.set(`mcp-${name}.md`, { type: 'inline', diff --git a/src/cli/client.ts b/src/cli/client.ts index 98527ed..93ed500 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -85,20 +85,11 @@ function parseArgv(argv: string[]): { process.exit(2); } - // Single word: `ncl help` - // Two words: `ncl groups list`, `ncl groups help` - // Three words: `ncl groups get abc123` - let command: string; - if (positional.length === 1) { - command = positional[0]; - } else { - command = `${positional[0]}-${positional[1]}`; - } - - // Third positional is the target ID - if (positional.length >= 3) { - args.id = positional[2]; - } + // Join all positionals with dashes to form the command name. + // If the full name isn't a command, the dispatcher will try trimming + // the last segment and using it as the target ID (e.g. `groups get abc` + // → command "groups-get", id "abc"). + const command = positional.join('-'); return { command, args, json }; } diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index d50eaef..1cdb5c5 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -4,19 +4,38 @@ * ncl help — list all resources and commands * ncl groups help — show group resource details (verbs, columns, enums) */ +import { getContainerConfig } from '../../db/container-configs.js'; import { getResource, getResources } from '../crud.js'; +import type { CallerContext } from '../frame.js'; import { listCommands, register } from '../registry.js'; +const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']); + +function getCliScope(ctx: CallerContext): string | undefined { + if (ctx.caller !== 'agent') return undefined; + return getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group'; +} + register({ name: 'help', description: 'List available resources and commands.', access: 'open', parseArgs: () => ({}), - handler: async () => { - const resources = getResources(); + handler: async (_args, ctx) => { + const cliScope = getCliScope(ctx); + let resources = getResources(); + if (cliScope === 'group') { + resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural)); + } const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource); const lines: string[] = []; + + if (cliScope === 'group') { + lines.push('CLI scope: group (--id and group args are auto-filled to your agent group)'); + lines.push(''); + } + if (resources.length > 0) { lines.push('Resources:'); for (const r of resources) { @@ -61,18 +80,27 @@ export function registerResourceHelpCommands(): void { access: 'open', resource: res.plural, parseArgs: () => ({}), - handler: async () => { + handler: async (_args, ctx) => { + const cliScope = getCliScope(ctx); const lines: string[] = []; lines.push(`${res.plural}: ${res.description}`); + + if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) { + lines.push(''); + lines.push('Note: --id and group args are auto-filled to your agent group. You do not need to pass them.'); + } + lines.push(''); // Verbs + const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations'); + const idHint = idAutoFilled ? '' : ' '; const verbs: string[] = []; if (res.operations.list) verbs.push(`list [open]`); - if (res.operations.get) verbs.push(`get [open]`); + if (res.operations.get) verbs.push(`get${idHint} [open]`); if (res.operations.create) verbs.push(`create [approval]`); - if (res.operations.update) verbs.push(`update [approval]`); - if (res.operations.delete) verbs.push(`delete [approval]`); + if (res.operations.update) verbs.push(`update${idHint} [approval]`); + if (res.operations.delete) verbs.push(`delete${idHint} [approval]`); if (res.customOperations) { for (const [verb, op] of Object.entries(res.customOperations)) { verbs.push(`${verb} [${op.access}] — ${op.description}`); @@ -83,9 +111,12 @@ export function registerResourceHelpCommands(): void { lines.push(''); // Columns + const autoFilledFields = + cliScope === 'group' ? new Set(['id', 'agent_group_id', 'group']) : new Set(); lines.push('Fields:'); for (const col of res.columns) { const tags: string[] = []; + if (autoFilledFields.has(col.name)) tags.push('auto-filled'); if (col.generated) tags.push('auto'); if (col.required) tags.push('required'); if (col.updatable) tags.push('updatable'); diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 928aeed..9c7ed99 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -279,7 +279,7 @@ export function registerResource(def: ResourceDef): void { if (def.customOperations) { for (const [verb, op] of Object.entries(def.customOperations)) { register({ - name: `${def.plural}-${verb}`, + name: `${def.plural}-${verb.replace(/ /g, '-')}`, description: op.description, access: op.access, resource: def.plural, diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts new file mode 100644 index 0000000..b63d712 --- /dev/null +++ b/src/cli/dispatch.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('../log.js', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const mockGetContainerConfig = vi.fn(); +vi.mock('../db/container-configs.js', () => ({ + getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args), +})); + +const mockGetAgentGroup = vi.fn(); +vi.mock('../db/agent-groups.js', () => ({ + getAgentGroup: (...args: unknown[]) => mockGetAgentGroup(...args), +})); + +const mockGetSession = vi.fn(); +vi.mock('../db/sessions.js', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})); + +vi.mock('../modules/approvals/index.js', () => ({ + registerApprovalHandler: vi.fn(), + requestApproval: vi.fn(), +})); + +// Register a test command so dispatch has something to find +import { register } from './registry.js'; + +register({ + name: 'test-cmd', + description: 'test command (non-group resource)', + resource: 'test', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'groups-test', + description: 'test command (groups resource)', + resource: 'groups', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'general-cmd', + description: 'test command (no resource, like help)', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'sessions-list', + description: 'test command (sessions resource)', + resource: 'sessions', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'destinations-list', + description: 'test command (destinations resource)', + resource: 'destinations', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'members-add', + description: 'test command (members resource)', + resource: 'members', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'wirings-list', + description: 'test command (wirings resource — not allowed)', + resource: 'wirings', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +// Commands that return data shaped like real resources (for post-handler filtering tests) +register({ + name: 'groups-list-data', + description: 'returns mock group rows', + resource: 'groups', + access: 'open', + parseArgs: (raw) => raw, + handler: async () => [ + { id: 'g1', name: 'my-group' }, + { id: 'g2', name: 'other-group' }, + ], +}); + +register({ + name: 'sessions-get-data', + description: 'returns a mock session row', + resource: 'sessions', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ + id: args.id, + agent_group_id: (args as Record).belongs_to ?? 'g1', + }), +}); + +import { dispatch } from './dispatch.js'; +import type { CallerContext } from './frame.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Helpers --- + +function agentCtx(overrides?: Partial>): CallerContext { + return { + caller: 'agent', + sessionId: 's1', + agentGroupId: 'g1', + messagingGroupId: 'mg1', + ...overrides, + }; +} + +// --- Tests --- + +describe('CLI scope enforcement', () => { + it('disabled: rejects all CLI requests from agent', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('disabled'); + } + }); + + it('group: auto-fills --id with caller agent group', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.id).toBe('g1'); + } + }); + + it('group: rejects cross-group access', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'other-group' } }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('scoped'); + } + }); + + it('group: allows same-group id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'g1' } }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('group: blocks cli_scope escalation', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('cli_scope'); + } + }); + + it('group: blocks cli-scope escalation (hyphenated)', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + + it('group: blocks non-group resources', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('test'); + } + }); + + it('group: allows general commands with no resource (e.g. help)', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'general-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('group: allows sessions, auto-fills --agent_group_id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'sessions-list', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.agent_group_id).toBe('g1'); + // --id should NOT be auto-filled for sessions (it's session UUID, not group) + expect(data.echo.id).toBeUndefined(); + } + }); + + it('group: allows destinations, auto-fills --id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'destinations-list', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.id).toBe('g1'); + } + }); + + it('group: allows members, auto-fills --group', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'members-add', args: { user: 'u1' } }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.group).toBe('g1'); + } + }); + + it('group: blocks non-whitelisted resources (wirings)', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('wirings'); + } + }); + + it('group: rejects cross-group --agent_group_id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'sessions-list', args: { agent_group_id: 'other-group' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + + it('group: rejects cross-group --group', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'members-add', args: { user: 'u1', group: 'other-group' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + + it('global: allows cross-group access', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'other-group' } }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('global: allows non-group resources', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('global: does not auto-fill --id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: { foo: 'bar' } }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.id).toBeUndefined(); + } + }); + + it('defaults to group when cli_scope is missing', async () => { + mockGetContainerConfig.mockReturnValue({}); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + + it('host caller bypasses CLI scope enforcement', async () => { + // No config check should happen for host callers + const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'any-group' } }, { caller: 'host' }); + + expect(resp.ok).toBe(true); + expect(mockGetContainerConfig).not.toHaveBeenCalled(); + }); + + // --- Post-handler filtering --- + + it('group: groups list filters out other groups', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as Array<{ id: string }>; + expect(data).toHaveLength(1); + expect(data[0].id).toBe('g1'); + } + }); + + it('group: sessions get rejects cross-group session', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'other-group' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('different agent group'); + } + }); + + it('group: sessions get allows own-group session', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'g1' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(true); + }); + + it('global: no post-handler filtering', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as Array<{ id: string }>; + expect(data).toHaveLength(2); // both groups returned + } + }); +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 7b247eb..68db969 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -6,6 +6,7 @@ * Approval gating for risky calls from the container is the only branch * that differs by caller. Host callers and `open` commands run inline. */ +import { getContainerConfig } from '../db/container-configs.js'; import { getAgentGroup } from '../db/agent-groups.js'; import { getSession } from '../db/sessions.js'; import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js'; @@ -13,11 +14,82 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr import { lookup } from './registry.js'; export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise { - const cmd = lookup(req.command); + let cmd = lookup(req.command); + + // Fallback: if the full command isn't registered, trim the last + // dash-segment and treat it as the target ID. This lets clients join + // all positional args with dashes (e.g. `ncl groups get abc123` + // → command "groups-get-abc123" → trim → "groups-get" + id "abc123"). + if (!cmd) { + const idx = req.command.lastIndexOf('-'); + if (idx > 0) { + const shortened = req.command.slice(0, idx); + const tail = req.command.slice(idx + 1); + const fallback = lookup(shortened); + if (fallback) { + cmd = fallback; + req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } }; + } + } + } + if (!cmd) { return err(req.id, 'unknown-command', `no command "${req.command}"`); } + // CLI scope enforcement for agent callers + if (ctx.caller === 'agent') { + const configRow = getContainerConfig(ctx.agentGroupId); + const cliScope = configRow?.cli_scope ?? 'group'; + + if (cliScope === 'disabled') { + return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.'); + } + + if (cliScope === 'group') { + const allowed = new Set(['groups', 'sessions', 'destinations', 'members']); + // Only allow whitelisted resources and general commands (no resource, like help) + if (cmd.resource && !allowed.has(cmd.resource)) { + return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`); + } + + // Enforce group scope on all agent-group-related args. + // Different resources use different arg names for the agent group ID. + // Only check --id for resources where it IS the agent group ID. + const groupArgs = ['agent_group_id', 'group'] as const; + for (const key of groupArgs) { + if (req.args[key] && req.args[key] !== ctx.agentGroupId) { + return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.'); + } + } + if ( + (cmd.resource === 'groups' || cmd.resource === 'destinations') && + req.args.id && + req.args.id !== ctx.agentGroupId + ) { + return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.'); + } + + // Block cli_scope changes from group-scoped agents (privilege escalation) + if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) { + return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.'); + } + + // Auto-fill agent-group-related args so the agent doesn't need + // to pass its own group ID explicitly. + const fill: Record = { + agent_group_id: req.args.agent_group_id ?? ctx.agentGroupId, + group: req.args.group ?? ctx.agentGroupId, + }; + // Only auto-fill --id for resources where it IS the agent group ID + // (groups, destinations). For sessions/members --id is a different key. + if (cmd.resource === 'groups' || cmd.resource === 'destinations') { + fill.id = req.args.id ?? ctx.agentGroupId; + } + req = { ...req, args: { ...req.args, ...fill } }; + } + } + if (ctx.caller !== 'host' && cmd.access === 'approval') { const session = getSession(ctx.sessionId); if (!session) { @@ -50,7 +122,31 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise + typeof row === 'object' && + row !== null && + (row as Record)[groupField] === ctx.agentGroupId, + ); + } else if (data && typeof data === 'object' && groupField in (data as Record)) { + if ((data as Record)[groupField] !== ctx.agentGroupId) { + return err(req.id, 'forbidden', 'Resource belongs to a different agent group.'); + } + } + } + } + return { id: req.id, ok: true, data }; } catch (e) { return err(req.id, 'handler-error', errMsg(e)); diff --git a/src/cli/frame.ts b/src/cli/frame.ts index 8e7604a..67cd61c 100644 --- a/src/cli/frame.ts +++ b/src/cli/frame.ts @@ -25,6 +25,7 @@ export type ErrorCode = | 'unknown-command' | 'invalid-args' | 'permission-denied' + | 'forbidden' | 'approval-pending' | 'not-found' | 'handler-error' diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index e334fc1..c6a19ec 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -1,5 +1,36 @@ +import type { McpServerConfig } from '../../container-config.js'; +import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; +import { restartAgentGroupContainers } from '../../container-restart.js'; +import { getSession } from '../../db/sessions.js'; +import { writeSessionMessage } from '../../session-manager.js'; +import { + getContainerConfig, + updateContainerConfigScalars, + updateContainerConfigJson, +} from '../../db/container-configs.js'; +import type { ContainerConfigRow } from '../../types.js'; import { registerResource } from '../crud.js'; +/** Deserialize JSON columns for display. */ +function presentConfig(row: ContainerConfigRow): Record { + return { + agent_group_id: row.agent_group_id, + provider: row.provider, + model: row.model, + effort: row.effort, + image_tag: row.image_tag, + assistant_name: row.assistant_name, + max_messages_per_prompt: row.max_messages_per_prompt, + skills: JSON.parse(row.skills), + mcp_servers: JSON.parse(row.mcp_servers), + packages_apt: JSON.parse(row.packages_apt), + packages_npm: JSON.parse(row.packages_npm), + additional_mounts: JSON.parse(row.additional_mounts), + cli_scope: row.cli_scope, + updated_at: row.updated_at, + }; +} + registerResource({ name: 'group', plural: 'groups', @@ -23,15 +54,222 @@ registerResource({ 'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.', required: true, }, - { - name: 'agent_provider', - type: 'string', - description: - 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', - updatable: true, - default: null, - }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, + customOperations: { + restart: { + access: 'approval', + description: + 'Restart containers for a group. Use --id [--rebuild] [--message ]. ' + + 'From inside a container, --id is auto-filled and only the calling session is restarted. ' + + '--rebuild rebuilds the container image first. --message sets an on-wake message for the fresh container; ' + + 'if omitted, containers come back on the next user message.', + handler: async (args, ctx) => { + const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined); + if (!id) throw new Error('--id is required'); + if (args.rebuild) { + await buildAgentGroupImage(id); + } + const message = args.message as string | undefined; + + // From an agent: scope to the calling session only + if (ctx.caller === 'agent') { + if (message) { + writeSessionMessage(id, ctx.sessionId, { + id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }), + onWake: 1, + }); + } + killContainer( + ctx.sessionId, + 'restarted via ncl', + message + ? () => { + const s = getSession(ctx.sessionId); + if (s) wakeContainer(s); + } + : undefined, + ); + return { restarted: 1, rebuilt: !!args.rebuild }; + } + + // From the host: restart all running containers in the group + const count = restartAgentGroupContainers(id, 'restarted via ncl', message); + return { restarted: count, rebuilt: !!args.rebuild }; + }, + }, + 'config get': { + access: 'open', + description: 'Show the container config for a group. Use --id .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + return presentConfig(row); + }, + }, + 'config update': { + access: 'approval', + description: + 'Update container config scalar fields. Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const updates: Partial< + Pick< + ContainerConfigRow, + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope' + > + > = {}; + if (args.provider !== undefined) updates.provider = args.provider as string; + if (args.model !== undefined) updates.model = args.model as string; + if (args.effort !== undefined) updates.effort = args.effort as string; + if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string; + if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string; + if (args.max_messages_per_prompt !== undefined) + updates.max_messages_per_prompt = Number(args.max_messages_per_prompt); + if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) { + const scope = (args['cli-scope'] ?? args.cli_scope) as string; + if (!['disabled', 'group', 'global'].includes(scope)) { + throw new Error('--cli-scope must be one of: disabled, group, global'); + } + updates.cli_scope = scope; + } + + if (Object.keys(updates).length === 0) { + throw new Error( + 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope', + ); + } + + updateContainerConfigScalars(id, updates); + + const updated = getContainerConfig(id)!; + return presentConfig(updated); + }, + }, + 'config add-mcp-server': { + access: 'approval', + description: + 'Add an MCP server to a group. Use --id --name --command [--args ] [--env ].', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const name = args.name as string; + if (!name) throw new Error('--name is required'); + const command = args.command as string; + if (!command) throw new Error('--command is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const servers = JSON.parse(row.mcp_servers) as Record; + servers[name] = { + command, + args: args.args ? (JSON.parse(args.args as string) as string[]) : [], + env: args.env ? (JSON.parse(args.env as string) as Record) : {}, + }; + updateContainerConfigJson(id, 'mcp_servers', servers); + + return { added: name, servers }; + }, + }, + 'config remove-mcp-server': { + access: 'approval', + description: 'Remove an MCP server from a group. Use --id --name .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const name = args.name as string; + if (!name) throw new Error('--name is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const servers = JSON.parse(row.mcp_servers) as Record; + if (!servers[name]) throw new Error(`MCP server "${name}" not found`); + delete servers[name]; + updateContainerConfigJson(id, 'mcp_servers', servers); + + return { removed: name }; + }, + }, + 'config add-package': { + access: 'approval', + description: 'Add a package to a group. Use --id and --apt or --npm .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const apt = args.apt as string | undefined; + const npm = args.npm as string | undefined; + if (!apt && !npm) throw new Error('Provide --apt or --npm '); + + if (apt) { + const existing = JSON.parse(row.packages_apt) as string[]; + if (!existing.includes(apt)) { + existing.push(apt); + updateContainerConfigJson(id, 'packages_apt', existing); + } + } + if (npm) { + const existing = JSON.parse(row.packages_npm) as string[]; + if (!existing.includes(npm)) { + existing.push(npm); + updateContainerConfigJson(id, 'packages_npm', existing); + } + } + + return { + added: { apt: apt || null, npm: npm || null }, + note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.', + }; + }, + }, + 'config remove-package': { + access: 'approval', + description: 'Remove a package from a group. Use --id and --apt or --npm .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const apt = args.apt as string | undefined; + const npm = args.npm as string | undefined; + if (!apt && !npm) throw new Error('Provide --apt or --npm '); + + if (apt) { + const existing = JSON.parse(row.packages_apt) as string[]; + const filtered = existing.filter((p) => p !== apt); + updateContainerConfigJson(id, 'packages_apt', filtered); + } + if (npm) { + const existing = JSON.parse(row.packages_npm) as string[]; + const filtered = existing.filter((p) => p !== npm); + updateContainerConfigJson(id, 'packages_npm', filtered); + } + + return { + removed: { apt: apt || null, npm: npm || null }, + note: 'Image rebuild required for package changes to take effect.', + }; + }, + }, + }, }); diff --git a/src/container-config.ts b/src/container-config.ts index d972842..597ca92 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -1,26 +1,25 @@ /** - * Per-group container config, stored as a plain JSON file at - * `groups//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. + * Container config types and materialization. * - * 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 - * worth the ceremony here since there's only one writer in practice: the - * host, from the delivery thread that processes approved system actions). + * Source of truth is the `container_configs` table in the central DB. + * This module provides: + * - Type definitions for the file shape (read by the container runner) + * - `materializeContainerJson()` — writes `groups//container.json` + * from the DB at spawn time + * - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group */ import fs from 'fs'; import path from 'path'; import { GROUPS_DIR } from './config.js'; +import { getContainerConfig } from './db/container-configs.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import type { AgentGroup, ContainerConfigRow } from './types.js'; export interface McpServerConfig { command: string; args?: string[]; env?: Record; - // Optional always-in-context guidance. When set, the host writes the - // content to `.claude-fragments/mcp-.md` at spawn and imports it - // into the composed CLAUDE.md. instructions?: string; } @@ -30,101 +29,61 @@ export interface AdditionalMountConfig { readonly?: boolean; } +/** Shape of the materialized `container.json` file read by the container runner. */ export interface ContainerConfig { mcpServers: Record; 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; + model?: string; + effort?: string; } -function emptyConfig(): ContainerConfig { +/** Build a `ContainerConfig` from a DB row + agent group identity. */ +export function configFromDb(row: ContainerConfigRow, group: AgentGroup): ContainerConfig { return { - mcpServers: {}, - packages: { apt: [], npm: [] }, - additionalMounts: [], - skills: 'all', + mcpServers: JSON.parse(row.mcp_servers) as Record, + packages: { + apt: JSON.parse(row.packages_apt) as string[], + npm: JSON.parse(row.packages_npm) as string[], + }, + imageTag: row.image_tag ?? undefined, + additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[], + skills: JSON.parse(row.skills) as string[] | 'all', + provider: row.provider ?? undefined, + groupName: group.name, + assistantName: row.assistant_name ?? group.name, + agentGroupId: group.id, + maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined, + model: row.model ?? undefined, + effort: row.effort ?? undefined, }; } -function configPath(folder: string): string { - return path.join(GROUPS_DIR, folder, 'container.json'); -} - /** - * Read the container config for a group, returning sensible defaults for - * any missing fields (or an entirely empty config if the file is absent). - * Never throws for missing / malformed files — corruption logs a warning - * via console.error and falls back to empty. + * Materialize `container.json` from the DB. Called at spawn time so the + * container always sees fresh config. Returns the `ContainerConfig` for + * use by the caller (buildMounts, buildContainerArgs, etc.). */ -export function readContainerConfig(folder: string): ContainerConfig { - const p = configPath(folder); - if (!fs.existsSync(p)) return emptyConfig(); - try { - const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial; - return { - mcpServers: raw.mcpServers ?? {}, - packages: { - apt: raw.packages?.apt ?? [], - npm: raw.packages?.npm ?? [], - }, - 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)}`); - return emptyConfig(); - } -} +export function materializeContainerJson(agentGroupId: string): ContainerConfig { + const group = getAgentGroup(agentGroupId); + if (!group) throw new Error(`Agent group not found: ${agentGroupId}`); -/** - * Write the container config for a group, creating the groups// - * directory if necessary. Pretty-printed JSON so diffs in the activation - * flow are reviewable. - */ -export function writeContainerConfig(folder: string, config: ContainerConfig): void { - const p = configPath(folder); + const row = getContainerConfig(agentGroupId); + if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`); + + const config = configFromDb(row, group); + + const p = path.join(GROUPS_DIR, group.folder, 'container.json'); const dir = path.dirname(p); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n'); -} -/** - * Apply a mutator function to a group's container config and persist the - * result. Convenient for append-style changes like `install_packages` and - * `add_mcp_server` handlers. - */ -export function updateContainerConfig(folder: string, mutate: (config: ContainerConfig) => void): ContainerConfig { - const config = readContainerConfig(folder); - mutate(config); - writeContainerConfig(folder, config); return config; } - -/** - * Initialize an empty container.json for a group if one doesn't already - * exist. Idempotent — used from `group-init.ts`. - */ -export function initContainerConfig(folder: string): boolean { - const p = configPath(folder); - if (fs.existsSync(p)) return false; - writeContainerConfig(folder, emptyConfig()); - return true; -} diff --git a/src/container-restart.test.ts b/src/container-restart.test.ts new file mode 100644 index 0000000..d07d17f --- /dev/null +++ b/src/container-restart.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('./log.js', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const mockIsContainerRunning = vi.fn<(id: string) => boolean>(); +const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void) => void>(); +const mockWakeContainer = vi.fn(); +vi.mock('./container-runner.js', () => ({ + isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string), + killContainer: (...args: unknown[]) => + mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined), + wakeContainer: (...args: unknown[]) => mockWakeContainer(...args), +})); + +const mockGetSessionsByAgentGroup = vi.fn(); +const mockGetSession = vi.fn(); +vi.mock('./db/sessions.js', () => ({ + getSessionsByAgentGroup: (...args: unknown[]) => mockGetSessionsByAgentGroup(...args), + getSession: (...args: unknown[]) => mockGetSession(...args), +})); + +const mockWriteSessionMessage = vi.fn(); +vi.mock('./session-manager.js', () => ({ + writeSessionMessage: (...args: unknown[]) => mockWriteSessionMessage(...args), +})); + +import { restartAgentGroupContainers } from './container-restart.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Helpers --- + +function makeSession(id: string, agentGroupId: string, status = 'active') { + return { id, agent_group_id: agentGroupId, status }; +} + +// --- Tests --- + +describe('restartAgentGroupContainers', () => { + it('skips sessions without a running container', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]); + mockIsContainerRunning.mockReturnValue(false); + + const count = restartAgentGroupContainers('g1', 'test'); + + expect(count).toBe(0); + expect(mockKillContainer).not.toHaveBeenCalled(); + expect(mockWriteSessionMessage).not.toHaveBeenCalled(); + }); + + it('skips non-active sessions', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1', 'closed')]); + mockIsContainerRunning.mockReturnValue(true); + + const count = restartAgentGroupContainers('g1', 'test'); + + expect(count).toBe(0); + expect(mockKillContainer).not.toHaveBeenCalled(); + }); + + it('kills running containers and returns count', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]); + mockIsContainerRunning.mockImplementation((id) => id === 's1'); + + const count = restartAgentGroupContainers('g1', 'test'); + + expect(count).toBe(1); + expect(mockKillContainer).toHaveBeenCalledTimes(1); + expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined); + }); + + it('does not write wake message when wakeMessage is omitted', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + + restartAgentGroupContainers('g1', 'test'); + + expect(mockWriteSessionMessage).not.toHaveBeenCalled(); + expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined); + }); + + it('writes on_wake message and passes onExit callback when wakeMessage is provided', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + + restartAgentGroupContainers('g1', 'test', 'Resuming.'); + + // Should write an on-wake message + expect(mockWriteSessionMessage).toHaveBeenCalledTimes(1); + const [agentGroupId, sessionId, msg] = mockWriteSessionMessage.mock.calls[0]; + expect(agentGroupId).toBe('g1'); + expect(sessionId).toBe('s1'); + expect(msg.onWake).toBe(1); + expect(JSON.parse(msg.content).text).toBe('Resuming.'); + + // Should pass an onExit callback to killContainer + expect(mockKillContainer).toHaveBeenCalledTimes(1); + const onExit = mockKillContainer.mock.calls[0][2]; + expect(typeof onExit).toBe('function'); + }); + + it('onExit callback calls wakeContainer with refreshed session', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + const freshSession = makeSession('s1', 'g1'); + mockGetSession.mockReturnValue(freshSession); + + restartAgentGroupContainers('g1', 'test', 'Resuming.'); + + // Simulate container exit by calling the onExit callback + const onExit = mockKillContainer.mock.calls[0][2] as () => void; + onExit(); + + expect(mockGetSession).toHaveBeenCalledWith('s1'); + expect(mockWakeContainer).toHaveBeenCalledWith(freshSession); + }); + + it('onExit callback does not wake if session no longer exists', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + mockGetSession.mockReturnValue(undefined); + + restartAgentGroupContainers('g1', 'test', 'Resuming.'); + + const onExit = mockKillContainer.mock.calls[0][2] as () => void; + onExit(); + + expect(mockWakeContainer).not.toHaveBeenCalled(); + }); + + it('handles multiple running sessions with wake message', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + + const count = restartAgentGroupContainers('g1', 'test', 'Config updated.'); + + expect(count).toBe(2); + expect(mockKillContainer).toHaveBeenCalledTimes(2); + expect(mockWriteSessionMessage).toHaveBeenCalledTimes(2); + + // Each session gets its own on-wake message + expect(mockWriteSessionMessage.mock.calls[0][1]).toBe('s1'); + expect(mockWriteSessionMessage.mock.calls[1][1]).toBe('s2'); + }); +}); diff --git a/src/container-restart.ts b/src/container-restart.ts new file mode 100644 index 0000000..e09d6f3 --- /dev/null +++ b/src/container-restart.ts @@ -0,0 +1,59 @@ +/** + * Helper to restart all running containers for an agent group. + * + * Writes an on_wake message to each session, kills the container, then + * wakes a fresh container via the onExit callback — race-free. + */ +import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; +import { getSession, getSessionsByAgentGroup } from './db/sessions.js'; +import { log } from './log.js'; +import { writeSessionMessage } from './session-manager.js'; + +/** + * Kill all running containers for an agent group and respawn them. + * + * Only targets sessions that actually have a running container. + * If `wakeMessage` is provided, each session gets an on_wake message + * (picked up only by the fresh container's first poll) and a + * wakeContainer call on exit. Without it, containers are killed and + * only come back on the next real user message. + */ +export function restartAgentGroupContainers(agentGroupId: string, reason: string, wakeMessage?: string): number { + const sessions = getSessionsByAgentGroup(agentGroupId).filter( + (s) => s.status === 'active' && isContainerRunning(s.id), + ); + + for (const session of sessions) { + if (wakeMessage) { + writeSessionMessage(agentGroupId, session.id, { + id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: agentGroupId, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: wakeMessage, + sender: 'system', + senderId: 'system', + }), + onWake: 1, + }); + } + killContainer( + session.id, + reason, + wakeMessage + ? () => { + const s = getSession(session.id); + if (s) wakeContainer(s); + } + : undefined, + ); + } + + if (sessions.length > 0) { + log.info('Restarting agent group containers', { agentGroupId, reason, count: sessions.length }); + } + return sessions.length; +} diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index cd18a72..3c188f9 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest'; import { resolveProviderName } from './container-runner.js'; describe('resolveProviderName', () => { - it('prefers session over group and container.json', () => { - expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex'); + it('prefers session over container config', () => { + expect(resolveProviderName('codex', 'claude')).toBe('codex'); }); - it('falls back to group when session is null', () => { - expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex'); - }); - - it('falls back to container.json when session and group are null', () => { - expect(resolveProviderName(null, null, 'opencode')).toBe('opencode'); + it('falls back to container config when session is null', () => { + expect(resolveProviderName(null, 'opencode')).toBe('opencode'); }); it('defaults to claude when nothing is set', () => { - expect(resolveProviderName(null, null, undefined)).toBe('claude'); + expect(resolveProviderName(null, undefined)).toBe('claude'); }); it('lowercases the resolved name', () => { - expect(resolveProviderName('CODEX', null, null)).toBe('codex'); - expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode'); - expect(resolveProviderName(null, null, 'Claude')).toBe('claude'); + expect(resolveProviderName('CODEX', null)).toBe('codex'); + expect(resolveProviderName(null, 'Claude')).toBe('claude'); }); it('treats empty string as unset (falls through)', () => { - expect(resolveProviderName('', 'codex', null)).toBe('codex'); - expect(resolveProviderName(null, '', 'opencode')).toBe('opencode'); + expect(resolveProviderName('', 'opencode')).toBe('opencode'); + expect(resolveProviderName(null, '')).toBe('claude'); }); }); diff --git a/src/container-runner.ts b/src/container-runner.ts index 3ebef1a..7201bfc 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -19,7 +19,9 @@ import { ONECLI_URL, TIMEZONE, } from './config.js'; -import { readContainerConfig, writeContainerConfig } from './container-config.js'; +import { materializeContainerJson } from './container-config.js'; +import { getContainerConfig } from './db/container-configs.js'; +import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.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'; @@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise { } 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); + // Materialize container.json from DB — writes fresh file and returns + // the config object, threaded through provider resolution, buildMounts, + // and buildContainerArgs so we don't re-read. + const containerConfig = materializeContainerJson(agentGroup.id); // Resolve the effective provider + any host-side contribution it declares // (extra mounts, env passthrough). Computed once and threaded through both @@ -191,10 +190,14 @@ async function spawnContainer(session: Session): Promise { } /** Kill a container for a session. */ -export function killContainer(sessionId: string, reason: string): void { +export function killContainer(sessionId: string, reason: string, onExit?: () => void): void { const entry = activeContainers.get(sessionId); if (!entry) return; + if (onExit) { + entry.process.once('close', onExit); + } + log.info('Killing container', { sessionId, reason, containerName: entry.containerName }); try { stopContainer(entry.containerName); @@ -204,22 +207,19 @@ export function killContainer(sessionId: string, reason: string): void { } /** - * Resolve the provider name for a session using the precedence documented in - * the provider-install skills: + * Resolve the provider name for a session: * * sessions.agent_provider - * → agent_groups.agent_provider - * → container.json `provider` + * → container_configs.provider * → 'claude' * * Pure so the precedence can be unit-tested without a DB or filesystem. */ export function resolveProviderName( sessionProvider: string | null | undefined, - agentGroupProvider: string | null | undefined, containerConfigProvider: string | null | undefined, ): string { - return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase(); + return (sessionProvider || containerConfigProvider || 'claude').toLowerCase(); } function resolveProviderContribution( @@ -227,7 +227,7 @@ function resolveProviderContribution( agentGroup: AgentGroup, containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider); + const provider = resolveProviderName(session.agent_provider, containerConfig.provider); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ @@ -396,34 +396,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain } } -/** - * 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, @@ -497,10 +469,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise const agentGroup = getAgentGroup(agentGroupId); if (!agentGroup) throw new Error('Agent group not found'); - const containerConfig = readContainerConfig(agentGroup.folder); - const aptPackages = containerConfig.packages.apt; - const npmPackages = containerConfig.packages.npm; - + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) throw new Error('Container config not found'); + const aptPackages = JSON.parse(configRow.packages_apt) as string[]; + const npmPackages = JSON.parse(configRow.packages_npm) as string[]; if (aptPackages.length === 0 && npmPackages.length === 0) { throw new Error('No packages to install. Use install_packages first.'); } @@ -536,9 +508,8 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise fs.unlinkSync(tmpDockerfile); } - // Store the image tag in groups//container.json - containerConfig.imageTag = imageTag; - writeContainerConfig(agentGroup.folder, containerConfig); + // Store the image tag in the DB + updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag }); log.info('Per-agent-group image built', { agentGroupId, imageTag }); } diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts new file mode 100644 index 0000000..219c73f --- /dev/null +++ b/src/db/container-configs.ts @@ -0,0 +1,97 @@ +import type { ContainerConfigRow } from '../types.js'; +import { getDb } from './connection.js'; + +const SCALAR_COLUMNS = new Set([ + 'provider', + 'model', + 'effort', + 'image_tag', + 'assistant_name', + 'max_messages_per_prompt', + 'cli_scope', +]); +const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']); + +export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined { + return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as + | ContainerConfigRow + | undefined; +} + +export function getAllContainerConfigs(): ContainerConfigRow[] { + return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[]; +} + +/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */ +export function createContainerConfig(config: ContainerConfigRow): void { + getDb() + .prepare( + `INSERT INTO container_configs ( + agent_group_id, provider, model, effort, image_tag, assistant_name, + max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm, + additional_mounts, updated_at + ) VALUES ( + @agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name, + @max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm, + @additional_mounts, @updated_at + )`, + ) + .run(config); +} + +/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */ +export function ensureContainerConfig(agentGroupId: string): void { + getDb() + .prepare( + `INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at) + VALUES (?, ?)`, + ) + .run(agentGroupId, new Date().toISOString()); +} + +/** Update scalar fields on a config row. Only touches fields present in `updates`. */ +export function updateContainerConfigScalars( + agentGroupId: string, + updates: Partial< + Pick< + ContainerConfigRow, + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope' + > + >, +): void { + const fields: string[] = []; + const values: Record = { agent_group_id: agentGroupId }; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + if (!SCALAR_COLUMNS.has(key)) throw new Error(`Invalid scalar column: ${key}`); + fields.push(`${key} = @${key}`); + values[key] = value; + } + } + if (fields.length === 0) return; + + fields.push('updated_at = @updated_at'); + values.updated_at = new Date().toISOString(); + + getDb() + .prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`) + .run(values); +} + +/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */ +export function updateContainerConfigJson( + agentGroupId: string, + column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts', + value: unknown, +): void { + if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`); + const now = new Date().toISOString(); + getDb() + .prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`) + .run(JSON.stringify(value), now, agentGroupId); +} + +export function deleteContainerConfig(agentGroupId: string): void { + getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId); +} diff --git a/src/db/index.ts b/src/db/index.ts index 0e4285a..57a1013 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -42,3 +42,12 @@ export { deletePendingApproval, getPendingApprovalsByAction, } from './sessions.js'; +export { + getContainerConfig, + getAllContainerConfigs, + createContainerConfig, + ensureContainerConfig, + updateContainerConfigScalars, + updateContainerConfigJson, + deleteContainerConfig, +} from './container-configs.js'; diff --git a/src/db/migrations/014-container-configs.ts b/src/db/migrations/014-container-configs.ts new file mode 100644 index 0000000..b9e3968 --- /dev/null +++ b/src/db/migrations/014-container-configs.ts @@ -0,0 +1,26 @@ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration014: Migration = { + version: 14, + name: 'container-configs', + up(db: Database.Database) { + db.exec(` + CREATE TABLE container_configs ( + agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE, + provider TEXT, + model TEXT, + effort TEXT, + image_tag TEXT, + assistant_name TEXT, + max_messages_per_prompt INTEGER, + skills TEXT NOT NULL DEFAULT '"all"', + mcp_servers TEXT NOT NULL DEFAULT '{}', + packages_apt TEXT NOT NULL DEFAULT '[]', + packages_npm TEXT NOT NULL DEFAULT '[]', + additional_mounts TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL + ); + `); + }, +}; diff --git a/src/db/migrations/015-cli-scope.ts b/src/db/migrations/015-cli-scope.ts new file mode 100644 index 0000000..6c0c7dd --- /dev/null +++ b/src/db/migrations/015-cli-scope.ts @@ -0,0 +1,10 @@ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration015: Migration = { + version: 15, + name: 'cli-scope', + up(db: Database.Database) { + db.prepare("ALTER TABLE container_configs ADD COLUMN cli_scope TEXT NOT NULL DEFAULT 'group'").run(); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index b46e678..0cefb37 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -10,6 +10,8 @@ import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; import { migration013 } from './013-approval-render-metadata.js'; +import { migration014 } from './014-container-configs.js'; +import { migration015 } from './015-cli-scope.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -31,6 +33,8 @@ const migrations: Migration[] = [ migration011, migration012, migration013, + migration014, + migration015, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 48d9ce3..56701e6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -7,8 +7,7 @@ export const SCHEMA = ` -- Agent workspaces: folder, skills, CLAUDE.md. -- All workspaces are equal; privilege lives on users, not groups. --- Container config (mcpServers, packages, imageTag, additionalMounts) lives --- in groups//container.json on disk, not in the DB. +-- Container config lives in the container_configs table (see migration 014). CREATE TABLE agent_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -177,7 +176,10 @@ CREATE TABLE IF NOT EXISTS messages_in ( -- the reply routes back to this exact session, not to the source agent -- group's "newest" session. NULL on channel-side inbound and on a2a rows -- written before this column existed. - source_session_id TEXT + source_session_id TEXT, + on_wake INTEGER NOT NULL DEFAULT 0 + -- 1 = only deliver on the container's first poll (fresh start). + -- Dying containers (past first poll) skip these rows. ); CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id); diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 6713702..15ba0e4 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -114,14 +114,20 @@ export function insertMessage( * path for the target's reply. NULL on channel-side inbound. */ sourceSessionId?: string | null; + /** + * 1 = only deliver on the container's first poll (fresh start). + * Dying containers (past first poll) skip these rows. + */ + onWake?: 0 | 1; }, ): void { db.prepare( - `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id) - VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId)`, + `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id, on_wake) + VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId, @onWake)`, ).run({ ...message, trigger: message.trigger ?? 1, + onWake: message.onWake ?? 0, sourceSessionId: message.sourceSessionId ?? null, seq: nextEvenSeq(db), }); @@ -318,6 +324,11 @@ export function migrateMessagesInTable(db: Database.Database): void { // their replies fall back to the legacy "newest active session" lookup. db.prepare('ALTER TABLE messages_in ADD COLUMN source_session_id TEXT').run(); } + if (!cols.has('on_wake')) { + // 1 = only deliver on the container's first poll (fresh start). + // All existing rows are normal messages, so default 0. + db.prepare('ALTER TABLE messages_in ADD COLUMN on_wake INTEGER NOT NULL DEFAULT 0').run(); + } } /** diff --git a/src/group-init.ts b/src/group-init.ts index b325150..e6d919b 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { DATA_DIR, GROUPS_DIR } from './config.js'; -import { initContainerConfig } from './container-config.js'; +import { ensureContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; @@ -65,12 +65,10 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('CLAUDE.local.md'); } - // groups//container.json — empty container config, replaces the - // former agent_groups.container_config DB column. Self-modification flows - // read and write this file directly. - if (initContainerConfig(group.folder)) { - initialized.push('container.json'); - } + // Ensure container_configs row exists in the DB. Idempotent — no-op if + // the row already exists (e.g. created by backfill or group creation). + ensureContainerConfig(group.id); + initialized.push('container_configs'); // 2. data/v2-sessions//.claude-shared/ — Claude state + per-group skills const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared'); diff --git a/src/index.ts b/src/index.ts index f16992a..6af9b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ */ import path from 'path'; +import { backfillContainerConfigs } from './backfill-container-configs.js'; import { DATA_DIR } from './config.js'; import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; @@ -74,7 +75,11 @@ async function main(): Promise { runMigrations(db); log.info('Central DB ready', { path: dbPath }); - // 1b. One-time filesystem cutover — idempotent, no-op after first run. + // 1b. Backfill container_configs from legacy container.json files. + // Idempotent — skips groups that already have a config row. + backfillContainerConfigs(); + + // 1c. One-time filesystem cutover — idempotent, no-op after first run. migrateGroupsToClaudeLocal(); // 2. Container runtime diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index 5291937..c5318ff 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -3,17 +3,18 @@ * * The approvals module calls these when an admin clicks Approve on a * pending_approvals row whose action matches. Each handler mutates the - * container config, rebuilds/kills the container as needed, and lets the - * host sweep respawn it on the new image on the next message. + * container config in the DB, rebuilds/kills the container as needed, + * and writes an on_wake message so the fresh container picks up where + * the old one left off. * - * install_packages: rebuild image + kill container (apt/npm global installs - * must be baked into the image layer). - * add_mcp_server: kill container only — bun runs TS directly, so a pure - * MCP wiring change needs nothing more than a process restart. + * install_packages: update DB + rebuild image + kill container + on_wake. + * add_mcp_server: update DB + kill container + on_wake. */ -import { updateContainerConfig } from '../../container-config.js'; -import { buildAgentGroupImage, killContainer } from '../../container-runner.js'; +import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; import { getAgentGroup } from '../../db/agent-groups.js'; +import { getContainerConfig, updateContainerConfigJson } from '../../db/container-configs.js'; +import { getSession } from '../../db/sessions.js'; +import type { McpServerConfig } from '../../container-config.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; import type { ApprovalHandler } from '../approvals/index.js'; @@ -24,10 +25,28 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, notify('install_packages approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[])); - if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[])); - }); + + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('install_packages approved but container config missing.'); + return; + } + + // Append new packages to existing lists in the DB (deduplicated) + if (payload.apt) { + const existing = JSON.parse(configRow.packages_apt) as string[]; + for (const pkg of payload.apt as string[]) { + if (!existing.includes(pkg)) existing.push(pkg); + } + updateContainerConfigJson(agentGroup.id, 'packages_apt', existing); + } + if (payload.npm) { + const existing = JSON.parse(configRow.packages_npm) as string[]; + for (const pkg of payload.npm as string[]) { + if (!existing.includes(pkg)) existing.push(pkg); + } + updateContainerConfigJson(agentGroup.id, 'packages_npm', existing); + } const pkgs = [ ...((payload.apt as string[] | undefined) || []), @@ -36,9 +55,6 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, log.info('Package install approved', { agentGroupId: session.agent_group_id, userId }); try { await buildAgentGroupImage(session.agent_group_id); - killContainer(session.id, 'rebuild applied'); - // Schedule a follow-up prompt a few seconds after kill so the host sweep - // respawns the container on the new image and the agent verifies + reports. writeSessionMessage(session.agent_group_id, session.id, { id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', @@ -51,10 +67,11 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, sender: 'system', senderId: 'system', }), - processAfter: new Date(Date.now() + 5000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ''), + onWake: 1, + }); + killContainer(session.id, 'rebuild applied', () => { + const s = getSession(session.id); + if (s) wakeContainer(s); }); log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id }); } catch (e) { @@ -71,15 +88,39 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use notify('add_mcp_server approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - cfg.mcpServers[payload.name as string] = { - command: payload.command as string, - args: (payload.args as string[]) || [], - env: (payload.env as Record) || {}, - }; - }); - killContainer(session.id, 'mcp server added'); - notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('add_mcp_server approved but container config missing.'); + return; + } + + // Add the new MCP server to the existing map in the DB + const servers = JSON.parse(configRow.mcp_servers) as Record; + servers[payload.name as string] = { + command: payload.command as string, + args: (payload.args as string[]) || [], + env: (payload.env as Record) || {}, + }; + updateContainerConfigJson(agentGroup.id, 'mcp_servers', servers); + + writeSessionMessage(session.agent_group_id, session.id, { + id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: `MCP server "${payload.name}" added. Verify it's available (e.g. list your tools) and report the result to the user.`, + sender: 'system', + senderId: 'system', + }), + onWake: 1, + }); + killContainer(session.id, 'mcp server added', () => { + const s = getSession(session.id); + if (s) wakeContainer(s); + }); log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId }); }; diff --git a/src/session-manager.ts b/src/session-manager.ts index 5c423ea..38c77f2 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -216,6 +216,11 @@ export function writeSessionMessage( * path so the target's reply routes back to that exact session. */ sourceSessionId?: string | null; + /** + * 1 = only deliver on the container's first poll (fresh start). + * Dying containers (past first poll) skip these rows. + */ + onWake?: 0 | 1; }, ): void { // Extract base64 attachment data, save to inbox, replace with file paths @@ -235,6 +240,7 @@ export function writeSessionMessage( recurrence: message.recurrence ?? null, trigger: message.trigger ?? 1, sourceSessionId: message.sourceSessionId ?? null, + onWake: message.onWake ?? 0, }); } finally { db.close(); diff --git a/src/types.ts b/src/types.ts index b3e2470..26a40f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,10 +4,30 @@ export interface AgentGroup { id: string; name: string; folder: string; + /** @deprecated Use container_configs.provider instead. */ agent_provider: string | null; created_at: string; } +/** Per-agent-group container runtime config. Source of truth in the DB; + * materialized to `groups//container.json` at spawn time. */ +export interface ContainerConfigRow { + agent_group_id: string; + provider: string | null; + model: string | null; + effort: string | null; + image_tag: string | null; + assistant_name: string | null; + max_messages_per_prompt: number | null; + skills: string; // JSON: '"all"' | '["skill1","skill2"]' + mcp_servers: string; // JSON: Record + packages_apt: string; // JSON: string[] + packages_npm: string; // JSON: string[] + additional_mounts: string; // JSON: AdditionalMountConfig[] + cli_scope: string; // 'disabled' | 'group' | 'global' + updated_at: string; +} + export type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public'; export interface MessagingGroup {