refactor(modules): re-tier approvals as default; extract self-mod as optional
Promotes approvals to the default tier with a public API (requestApproval + registerApprovalHandler) that other modules consume. Self-modification (install_packages / request_rebuild / add_mcp_server) moves into a new optional module that registers delivery actions + matching approval handlers via the new API. ## Approvals (default tier) - Adds `src/modules/approvals/primitive.ts` exporting `requestApproval`, `registerApprovalHandler`, `notifyAgent`. Absorbs `pickApprover` / `pickApprovalDelivery` / `channelTypeOf` from the deleted `src/access.ts`. - Rewrites `response-handler.ts` to dispatch to registered approval handlers on approve (action-keyed Map). Reject path is centralized. - Drops the three self-mod-specific delivery-action registrations from `approvals/index.ts`; they belong to self-mod now. - `onecli-approvals.ts` now imports picks from the primitive instead of `src/access.ts`. ## Self-mod (optional tier) - New `src/modules/self-mod/` with request handlers (validate input + call requestApproval) and apply handlers (orchestration on approve). - `apply.ts` owns updateContainerConfig + buildAgentGroupImage + killContainer calls. Self-mod depends on approvals (via registerApprovalHandler + requestApproval + notifyAgent) and on core (container-runner, container-config). - Registers 3 delivery actions + 3 approval handlers at import time. ## Other changes - `src/access.ts` and `src/access.test.ts` deleted. Tests split across `src/modules/approvals/picks.test.ts` (approver selection) and `src/modules/permissions/permissions.test.ts` (access + roles + DM). - `src/modules/index.ts` barrel: approvals loads before self-mod so registerApprovalHandler is bound when self-mod registers at import time. ## Validation - `pnpm run build` clean - `pnpm test` — 137 host tests pass - `bun test` in container/agent-runner — 17 tests pass - Service starts; boot log shows `OneCLI approval handler started`, `NanoClaw running`; clean SIGTERM shutdown Resolves the transitional tier violation flagged in PR #5 where core imported from the permissions optional module via `src/access.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
31
src/modules/self-mod/agent.md
Normal file
31
src/modules/self-mod/agent.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Self-modification
|
||||
|
||||
You can install additional OS or npm packages, rebuild your container image,
|
||||
or add new MCP servers — but only with admin approval.
|
||||
|
||||
## Tools
|
||||
|
||||
- `install_packages({ apt?: string[], npm?: string[], reason?: string })` —
|
||||
adds the listed packages to your container config and rebuilds the image
|
||||
after admin approval. Package names are validated strictly (`[a-z0-9._+-]`
|
||||
for apt, standard npm naming with optional scope). Max 20 packages per
|
||||
request.
|
||||
|
||||
- `request_rebuild({ reason?: string })` — rebuilds your container image
|
||||
without config changes. Useful if the image has drifted from config.
|
||||
|
||||
- `add_mcp_server({ name, command, args?, env? })` — adds a new MCP server
|
||||
to your container config. The container restarts on next message so the
|
||||
new server is available.
|
||||
|
||||
## Flow
|
||||
|
||||
You call one of these tools → the host asks an admin via DM → admin approves
|
||||
or rejects. On approve, the config is applied and the container is killed;
|
||||
the host respawns it on the next message. You'll get a system chat message
|
||||
confirming the outcome (either "Packages installed..." or a failure reason).
|
||||
|
||||
On reject you'll see "Your X request was rejected by admin."
|
||||
|
||||
If no admin is configured or reachable, the request fails immediately with
|
||||
a chat notification explaining why.
|
||||
89
src/modules/self-mod/apply.ts
Normal file
89
src/modules/self-mod/apply.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Approval handlers for self-modification actions.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { updateContainerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer } from '../../container-runner.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { ApprovalHandler } from '../approvals/index.js';
|
||||
|
||||
export const applyInstallPackages: ApprovalHandler = async ({ session, payload, userId, notify }) => {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
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 pkgs = [...((payload.apt as string[] | undefined) || []), ...((payload.npm as string[] | undefined) || [])].join(', ');
|
||||
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',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
text: `Packages installed (${pkgs}) and container rebuilt. Verify the new packages are available (e.g. run them or check versions) and report the result to the user.`,
|
||||
sender: 'system',
|
||||
senderId: 'system',
|
||||
}),
|
||||
processAfter: new Date(Date.now() + 5000)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z$/, ''),
|
||||
});
|
||||
log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id });
|
||||
} catch (e) {
|
||||
notify(
|
||||
`Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Call request_rebuild to retry.`,
|
||||
);
|
||||
log.error('Bundled rebuild failed after install approval', { agentGroupId: session.agent_group_id, err: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const applyRequestRebuild: ApprovalHandler = async ({ session, userId, notify }) => {
|
||||
try {
|
||||
await buildAgentGroupImage(session.agent_group_id);
|
||||
killContainer(session.id, 'rebuild applied');
|
||||
notify('Container image rebuilt. Your container will restart with the new image on the next message.');
|
||||
log.info('Container rebuild approved and completed', { agentGroupId: session.agent_group_id, userId });
|
||||
} catch (e) {
|
||||
notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
log.error('Container rebuild failed', { agentGroupId: session.agent_group_id, err: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, userId, notify }) => {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
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.`);
|
||||
log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId });
|
||||
};
|
||||
28
src/modules/self-mod/index.ts
Normal file
28
src/modules/self-mod/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Self-modification module — admin-approved container mutations.
|
||||
*
|
||||
* Optional tier. Depends on the approvals default module for the request/
|
||||
* handler plumbing. On install the module registers:
|
||||
* - Three delivery actions (install_packages, request_rebuild, add_mcp_server)
|
||||
* that validate input and queue an approval via requestApproval().
|
||||
* - Three matching approval handlers that run on approve: mutate the
|
||||
* container config, rebuild the image, kill the container so the next
|
||||
* wake picks up the change.
|
||||
*
|
||||
* Without this module: the three MCP tools in the container still write
|
||||
* outbound system messages with these actions, but delivery logs
|
||||
* "Unknown system action" and drops them. Admin never sees a card; nothing
|
||||
* changes.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { applyAddMcpServer, applyInstallPackages, applyRequestRebuild } from './apply.js';
|
||||
import { handleAddMcpServer, handleInstallPackages, handleRequestRebuild } from './request.js';
|
||||
|
||||
registerDeliveryAction('install_packages', handleInstallPackages);
|
||||
registerDeliveryAction('request_rebuild', handleRequestRebuild);
|
||||
registerDeliveryAction('add_mcp_server', handleAddMcpServer);
|
||||
|
||||
registerApprovalHandler('install_packages', applyInstallPackages);
|
||||
registerApprovalHandler('request_rebuild', applyRequestRebuild);
|
||||
registerApprovalHandler('add_mcp_server', applyAddMcpServer);
|
||||
34
src/modules/self-mod/project.md
Normal file
34
src/modules/self-mod/project.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Self-mod module
|
||||
|
||||
Optional-tier module that gives agents admin-gated self-modification:
|
||||
installing OS/npm packages, rebuilding the container image, and registering
|
||||
new MCP servers. All three paths go through the approvals module's request
|
||||
primitive — no unapproved changes ever land.
|
||||
|
||||
## What this module adds
|
||||
|
||||
- Three delivery actions (`install_packages`, `request_rebuild`, `add_mcp_server`)
|
||||
that the container's self-mod MCP tools write into outbound.db. On the host,
|
||||
each handler validates input and queues an approval via
|
||||
`approvals.requestApproval()`.
|
||||
- Three matching approval handlers that run on approve: mutate the container
|
||||
config via `updateContainerConfig`, rebuild the image via
|
||||
`buildAgentGroupImage`, and kill the container so the host sweep respawns
|
||||
it on the new image.
|
||||
|
||||
## Dependency
|
||||
|
||||
Self-mod depends on the approvals default module for:
|
||||
- `requestApproval()` to enqueue admin confirmation cards
|
||||
- `registerApprovalHandler(action, handler)` to run orchestration on approve
|
||||
- `notifyAgent()` to send failure feedback back to the requesting agent
|
||||
|
||||
It also calls core's `buildAgentGroupImage`, `killContainer`, and
|
||||
`updateContainerConfig`.
|
||||
|
||||
## Removing the module
|
||||
|
||||
Delete `src/modules/self-mod/` and its import line in `src/modules/index.ts`.
|
||||
The container's self-mod MCP tools will still write outbound system messages,
|
||||
but core delivery will log `"Unknown system action"` and drop them — no
|
||||
admin card, no container mutation.
|
||||
106
src/modules/self-mod/request.ts
Normal file
106
src/modules/self-mod/request.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Delivery-action handlers for agent-initiated self-modification requests.
|
||||
*
|
||||
* Three actions the container can write into messages_out (via the self-mod
|
||||
* MCP tools): install_packages, request_rebuild, add_mcp_server. Each one
|
||||
* validates input and queues an approval request. The admin's approval
|
||||
* triggers the matching approval handler in ./apply.ts.
|
||||
*
|
||||
* Host-side sanitization for install_packages is defense-in-depth — the MCP
|
||||
* tool validates first. Both layers matter: the DB row carries the payload
|
||||
* verbatim through to shell exec on apply.
|
||||
*/
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { notifyAgent, requestApproval } from '../approvals/index.js';
|
||||
|
||||
export async function handleInstallPackages(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'install_packages failed: agent group not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const apt = (content.apt as string[]) || [];
|
||||
const npm = (content.npm as string[]) || [];
|
||||
const reason = (content.reason as string) || '';
|
||||
|
||||
const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
|
||||
const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
const MAX_PACKAGES = 20;
|
||||
if (apt.length + npm.length === 0) {
|
||||
notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.');
|
||||
return;
|
||||
}
|
||||
if (apt.length + npm.length > MAX_PACKAGES) {
|
||||
notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
|
||||
return;
|
||||
}
|
||||
const invalidApt = apt.find((p) => !APT_RE.test(p));
|
||||
if (invalidApt) {
|
||||
notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`);
|
||||
log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt });
|
||||
return;
|
||||
}
|
||||
const invalidNpm = npm.find((p) => !NPM_RE.test(p));
|
||||
if (invalidNpm) {
|
||||
notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`);
|
||||
log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm });
|
||||
return;
|
||||
}
|
||||
|
||||
const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', ');
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: agentGroup.name,
|
||||
action: 'install_packages',
|
||||
payload: { apt, npm, reason },
|
||||
title: 'Install Packages Request',
|
||||
question: `Agent "${agentGroup.name}" is attempting to install a package + rebuild container:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleRequestRebuild(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'request_rebuild failed: agent group not found.');
|
||||
return;
|
||||
}
|
||||
const reason = (content.reason as string) || '';
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: agentGroup.name,
|
||||
action: 'request_rebuild',
|
||||
payload: { reason },
|
||||
title: 'Rebuild Request',
|
||||
question: `Agent "${agentGroup.name}" is attempting to rebuild container.${reason ? `\nReason: ${reason}` : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAddMcpServer(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'add_mcp_server failed: agent group not found.');
|
||||
return;
|
||||
}
|
||||
const serverName = content.name as string;
|
||||
const command = content.command as string;
|
||||
if (!serverName || !command) {
|
||||
notifyAgent(session, 'add_mcp_server failed: name and command are required.');
|
||||
return;
|
||||
}
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: agentGroup.name,
|
||||
action: 'add_mcp_server',
|
||||
payload: {
|
||||
name: serverName,
|
||||
command,
|
||||
args: (content.args as string[]) || [],
|
||||
env: (content.env as Record<string, string>) || {},
|
||||
},
|
||||
title: 'Add MCP Request',
|
||||
question: `Agent "${agentGroup.name}" is attempting to add a new MCP server:\n${serverName} (${command})`,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user