Compare commits

..

21 Commits

Author SHA1 Message Date
gavrielc f9d30e8b9c style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:25:11 +03:00
gavrielc 1c7623ca41 docs: update for container config DB, on-wake, and CLI scope
- CLAUDE.md: new key files, updated groups verbs, rewritten self-mod
  section, new Container Config and Container Restart sections
- db-central.md: container_configs table (§1.15), migrations 014+015
- db-session.md: messages_in schema with trigger, source_session_id,
  on_wake columns
- schema.ts: comment no longer references disk-based config
- cli.instructions.md: rewritten for scope-aware usage, auto-fill,
  restart/config ops, group-scoped examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:23:44 +03:00
gavrielc faeeba198e fix(security): block cli_scope escalation and cross-group data leaks
Group-scoped agents could previously:
- See all agent groups via `groups list` (generic list skips --id filter)
- Look up any session by UUID via `sessions get`
- Request cli_scope change to global via config update approval

Fixed by:
- Post-handler filtering: list results filtered, get results verified
  against caller's agent_group_id
- Pre-handler --id check scoped to resources where id IS the group ID
  (groups, destinations) so session UUIDs aren't falsely rejected
- cli_scope/cli-scope args blocked outright for group-scoped agents,
  before the approval gate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:17:13 +03:00
gavrielc 04e41fb0ef feat: default owner agent group to global CLI scope
When init-first-agent creates an agent group for an owner, set
cli_scope to 'global' so the owner's personal agent has full ncl
access. All other agent groups remain 'group'-scoped by default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:09:05 +03:00
gavrielc aebcffe180 feat: per-group CLI scope (disabled/group/global)
Add cli_scope column to container_configs with three levels:
- disabled: agent never learns about ncl (instructions excluded from
  CLAUDE.md) and host dispatch rejects any cli_request
- group (default): agent can only access groups, sessions, destinations,
  and members resources, scoped to its own agent group with auto-filled
  --id/--agent_group_id/--group args. Help output reflects the scope.
- global: unrestricted access (current behavior)

Enforcement is host-side only — no image rebuild or env var needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:02:31 +03:00
gavrielc be3a8a97c6 feat: race-free on-wake messages and explicit restart CLI
Decouple container restart from config updates — config CLI ops now only
write to the DB; restart is a separate `ncl groups restart` command with
--rebuild and --message flags. Add on_wake column to messages_in so wake
messages are only picked up by a fresh container's first poll, preventing
dying containers from stealing them during the SIGTERM grace window.
killContainer accepts an onExit callback for race-free respawn. Agent-
called restart auto-scopes to the calling session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 19:02:15 +03:00
gavrielc 08698da0d2 fix(cli): decouple package commands from docker build
config add/remove-package should only update the DB and restart.
Image rebuild is handled by the self-mod approval flow or manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:10:46 +03:00
gavrielc 9ce82588d9 refactor(cli): remove deprecated agent_provider from groups columns
Provider is now managed via `ncl groups config update --provider`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:08:18 +03:00
gavrielc 37b54968ce refactor(cli): use spaces in custom operation keys
Operation keys like 'config get' read naturally and crud.ts normalizes
spaces to dashes for the registry name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:07:13 +03:00
gavrielc 1efe28ccdc feat(cli): support space-separated multi-word verbs
`ncl groups config get` now works alongside `ncl groups config-get`.
Parser joins all positionals with dashes; dispatcher falls back by
trimming the last segment as a target ID (`ncl groups get abc123`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 12:04:45 +03:00
gavrielc 4c83a8193b style: move column whitelist consts to module top
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 22:36:58 +03:00
gavrielc 7eebcf74c2 fix: harden container config DB layer
- config-add/remove-package now rebuild image + restart containers
- Deduplicate packages in self-mod install_packages handler
- Add runtime whitelist guards for SQL column interpolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 22:33:42 +03:00
gavrielc 31ccc61b27 feat(db): move container config from filesystem to DB
Source of truth for container runtime config moves from
groups/<folder>/container.json to a new container_configs table.
The file becomes a materialized view written at spawn time.

- New container_configs table with scalar columns (provider, model,
  effort, image_tag, assistant_name, max_messages_per_prompt) and
  JSON columns (mcp_servers, packages_apt, packages_npm, skills,
  additional_mounts)
- Startup backfill seeds DB from existing container.json files
- materializeContainerJson() replaces readContainerConfig + ensureRuntimeFields
- Self-mod handlers (install_packages, add_mcp_server) write to DB
- Provider cascade simplified: session -> container_configs -> 'claude'
- ncl groups config-{get,update,add-mcp-server,remove-mcp-server,
  add-package,remove-package} custom operations
- restartAgentGroupContainers() helper for config change propagation
- Container side unchanged (still reads /workspace/agent/container.json)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 22:27:55 +03:00
gavrielc ef43cbb3d9 docs: remove migration fixes from changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:18:02 +03:00
gavrielc 0060c6b84a docs: add v2.0.45 changelog entry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:14:37 +03:00
gavrielc e6d470d831 docs: add ncl CLI to changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:14:02 +03:00
github-actions[bot] 0e11eaf186 docs: update token count to 166k tokens · 83% of context window 2026-05-08 18:05:57 +00:00
github-actions[bot] 4990994204 chore: bump version to 2.0.46 2026-05-08 18:05:53 +00:00
gavrielc 2d03c94252 Merge pull request #2350 from qwibitai/ncl
feat(cli): add ncl admin CLI
2026-05-08 21:05:29 +03:00
gavrielc 93ec82ce38 Merge pull request #2300 from alipgoldberg/setup/slack-member-id-card
setup: correct Slack member-ID card directions
2026-05-08 20:14:27 +03:00
exe.dev user 5213c98506 setup: correct Slack member-ID card directions
Slack's profile button is in the bottom-left of the desktop sidebar (not
the top-right), and the "More" overflow icon next to "Copy member ID" is
the vertical kebab `⋮`, not the horizontal `⋯`. Match what users actually
see in Slack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:13:23 +00:00
40 changed files with 1628 additions and 298 deletions
+3 -1
View File
@@ -6,8 +6,10 @@ For detailed release notes, see the [full changelog on the documentation site](h
## [Unreleased]
## [2.0.45] - 2026-05-08
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md``CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:<id>` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default.
## [2.0.0] - 2026-04-22
+28 -3
View File
@@ -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 (`<channel>:<handle>`) |
@@ -120,10 +123,32 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → 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/<folder>/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 <group-id> [--rebuild] [--message <text>]`. 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`.
+3 -6
View File
@@ -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 };
}
+2 -1
View File
@@ -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,
+4 -3
View File
@@ -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 [];
@@ -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 <resource> <verb> [<id>] [--flags]
ncl <resource> <verb> [--flags]
ncl <resource> 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 (`<channel>:<handle>`) |
| 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 <your-group-id>` 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 <resource> help` to see all available fields, types, enums, and which fields are required or updatable.
- Use `ncl <resource> 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.
+61 -4
View File
@@ -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()
+3 -1
View File
@@ -67,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
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
+29 -1
View File
@@ -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/<folder>/` 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/<folder>/` 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/<folder>/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.
+16 -13
View File
@@ -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);
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.45",
"version": "2.0.46",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="150k tokens, 75% of context window">
<title>150k tokens, 75% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="166k tokens, 83% of context window">
<title>166k tokens, 83% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">150k</text>
<text x="71" y="14">150k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">166k</text>
<text x="71" y="14">166k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -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<void> {
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,
+2 -2
View File
@@ -317,9 +317,9 @@ async function collectSlackUserId(): Promise<string> {
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (top right)',
' 1. In Slack, click your profile picture (bottom left)',
' 2. Click "Profile"',
' 3. Click the three dots () → "Copy member ID"',
' 3. Click the three dots () → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
+78
View File
@@ -0,0 +1,78 @@
/**
* One-time backfill: seed `container_configs` rows from existing
* `groups/<folder>/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<string, McpServerConfig>;
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 });
}
}
+10 -4
View File
@@ -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<string, McpServerConfig> = configRow
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
: {};
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
// 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 `<name>.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',
+5 -14
View File
@@ -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 };
}
+37 -6
View File
@@ -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 ? '' : ' <id>';
const verbs: string[] = [];
if (res.operations.list) verbs.push(`list [open]`);
if (res.operations.get) verbs.push(`get <id> [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 <id> [approval]`);
if (res.operations.delete) verbs.push(`delete <id> [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<string>();
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');
+1 -1
View File
@@ -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,
+405
View File
@@ -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<string, unknown>).belongs_to ?? 'g1',
}),
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Helpers ---
function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }>>): 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<string, unknown> };
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<string, unknown> };
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<string, unknown> };
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<string, unknown> };
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<string, unknown> };
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
}
});
});
+98 -2
View File
@@ -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<ResponseFrame> {
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<string, unknown> = {
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<R
}
try {
const data = await cmd.handler(parsed, ctx);
let data = await cmd.handler(parsed, ctx);
// Post-handler group scope enforcement: filter/verify results belong
// to the caller's agent group. Catches leaks that pre-handler auto-fill
// can't prevent (e.g. `groups list` where the id arg is skipped by the
// generic list handler, or `sessions get` by UUID).
if (ctx.caller === 'agent' && cmd.resource) {
const configRow = getContainerConfig(ctx.agentGroupId);
if ((configRow?.cli_scope ?? 'group') === 'group') {
const groupField = cmd.resource === 'groups' ? 'id' : 'agent_group_id';
if (Array.isArray(data)) {
data = data.filter(
(row) =>
typeof row === 'object' &&
row !== null &&
(row as Record<string, unknown>)[groupField] === ctx.agentGroupId,
);
} else if (data && typeof data === 'object' && groupField in (data as Record<string, unknown>)) {
if ((data as Record<string, unknown>)[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));
+1
View File
@@ -25,6 +25,7 @@ export type ErrorCode =
| 'unknown-command'
| 'invalid-args'
| 'permission-denied'
| 'forbidden'
| 'approval-pending'
| 'not-found'
| 'handler-error'
+246 -8
View File
@@ -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<string, unknown> {
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-<provider>.',
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 <group-id> [--rebuild] [--message <text>]. ' +
'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 <group-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 <group-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 <group-id> --name <server-name> --command <cmd> [--args <json-array>] [--env <json-object>].',
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<string, McpServerConfig>;
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<string, string>) : {},
};
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 <group-id> --name <server-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<string, McpServerConfig>;
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 <group-id> and --apt <pkg> or --npm <pkg>.',
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 <pkg> or --npm <pkg>');
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 <group-id> and --apt <pkg> or --npm <pkg>.',
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 <pkg> or --npm <pkg>');
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.',
};
},
},
},
});
+42 -83
View File
@@ -1,26 +1,25 @@
/**
* Per-group container config, stored as a plain JSON file at
* `groups/<folder>/container.json`. Mounted read-only inside the container
* at `/workspace/agent/container.json` — the runner reads it at startup but
* cannot modify it. Config changes go through the self-mod approval flow.
* 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/<folder>/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<string, string>;
// Optional always-in-context guidance. When set, the host writes the
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
// into the composed CLAUDE.md.
instructions?: string;
}
@@ -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<string, McpServerConfig>;
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<string, McpServerConfig>,
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<ContainerConfig>;
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/<folder>/
* 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;
}
+151
View File
@@ -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');
});
});
+59
View File
@@ -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;
}
+9 -14
View File
@@ -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');
});
});
+22 -51
View File
@@ -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<void> {
}
writeSessionRouting(agentGroup.id, session.id);
// Read container config once — threaded through provider resolution,
// buildMounts, and buildContainerArgs so we don't re-read the file.
const containerConfig = readContainerConfig(agentGroup.folder);
// Ensure container.json has the agent group identity fields the runner needs.
// Written at spawn time so the runner can read them from the RO mount.
ensureRuntimeFields(containerConfig, agentGroup);
// 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<void> {
}
/** 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<void>
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<void>
fs.unlinkSync(tmpDockerfile);
}
// Store the image tag in groups/<folder>/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 });
}
+97
View File
@@ -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<string, unknown> = { 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);
}
+9
View File
@@ -42,3 +42,12 @@ export {
deletePendingApproval,
getPendingApprovalsByAction,
} from './sessions.js';
export {
getContainerConfig,
getAllContainerConfigs,
createContainerConfig,
ensureContainerConfig,
updateContainerConfigScalars,
updateContainerConfigJson,
deleteContainerConfig,
} from './container-configs.js';
@@ -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
);
`);
},
};
+10
View File
@@ -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();
},
};
+4
View File
@@ -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 {
+5 -3
View File
@@ -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/<folder>/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);
+13 -2
View File
@@ -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();
}
}
/**
+5 -7
View File
@@ -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/<folder>/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/<id>/.claude-shared/ — Claude state + per-group skills
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
+6 -1
View File
@@ -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<void> {
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
+69 -28
View File
@@ -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<string, string>) || {},
};
});
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<string, McpServerConfig>;
servers[payload.name as string] = {
command: payload.command as string,
args: (payload.args as string[]) || [],
env: (payload.env as Record<string, string>) || {},
};
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 });
};
+6
View File
@@ -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();
+20
View File
@@ -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/<folder>/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<string, McpServerConfig>
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 {