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:
gavrielc
2026-04-18 19:41:26 +03:00
parent 1f179e07f2
commit 95fdec335a
14 changed files with 715 additions and 484 deletions

View File

@@ -0,0 +1,141 @@
/**
* Tests for pickApprover + pickApprovalDelivery — the approver-selection
* half of what used to live in src/access.ts. Moved here in PR #7 alongside
* the approvals re-tier.
*/
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import type { ChannelAdapter, OutboundMessage } from '../../channels/adapter.js';
import { initChannelAdapters, registerChannelAdapter, teardownChannelAdapters } from '../../channels/channel-registry.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../../db/index.js';
import { createUser } from '../permissions/db/users.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { pickApprovalDelivery, pickApprover } from './primitive.js';
function now(): string {
return new Date().toISOString();
}
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
});
afterEach(async () => {
await teardownChannelAdapters();
closeDb();
});
async function mountMockAdapter(
channelType: string,
openDM?: (handle: string) => Promise<string>,
): Promise<{ delivered: OutboundMessage[]; openDMCalls: string[] }> {
const delivered: OutboundMessage[] = [];
const openDMCalls: string[] = [];
const adapter: ChannelAdapter = {
name: channelType,
channelType,
supportsThreads: false,
async setup() {},
async teardown() {},
isConnected() {
return true;
},
async deliver(_platformId, _threadId, message) {
delivered.push(message);
return undefined;
},
async setTyping() {},
};
if (openDM) {
adapter.openDM = async (handle: string) => {
openDMCalls.push(handle);
return openDM(handle);
};
}
registerChannelAdapter(channelType, { factory: () => adapter });
await initChannelAdapters(() => ({
conversations: [],
onInbound: () => {},
onMetadata: () => {},
onAction: () => {},
}));
return { delivered, openDMCalls };
}
function seedAgentGroup(id: string): void {
createAgentGroup({
id,
name: id.toUpperCase(),
folder: id,
agent_provider: null,
created_at: now(),
});
}
function seedUser(id: string, kind: string): void {
createUser({ id, kind, display_name: null, created_at: now() });
}
describe('pickApprover', () => {
beforeEach(() => {
seedAgentGroup('ag-1');
seedAgentGroup('ag-2');
});
it('prefers scoped admins, then globals, then owners — deduplicated', () => {
seedUser('u-owner', 'telegram');
seedUser('u-ga', 'telegram');
seedUser('u-sa', 'telegram');
grantRole({ user_id: 'u-owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
grantRole({ user_id: 'u-ga', role: 'admin', agent_group_id: null, granted_by: null, granted_at: now() });
grantRole({ user_id: 'u-sa', role: 'admin', agent_group_id: 'ag-1', granted_by: null, granted_at: now() });
expect(pickApprover('ag-1')).toEqual(['u-sa', 'u-ga', 'u-owner']);
expect(pickApprover('ag-2')).toEqual(['u-ga', 'u-owner']);
expect(pickApprover(null)).toEqual(['u-ga', 'u-owner']);
});
it('returns empty list when nobody is privileged', () => {
expect(pickApprover('ag-1')).toEqual([]);
});
});
describe('pickApprovalDelivery', () => {
beforeEach(() => {
seedAgentGroup('ag-1');
});
it('returns the first reachable approver', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:111', 'telegram');
seedUser('telegram:222', 'telegram');
const result = await pickApprovalDelivery(['telegram:111', 'telegram:222'], 'telegram');
expect(result?.userId).toBe('telegram:111');
expect(result?.messagingGroup.platform_id).toBe('111');
});
it('prefers same-channel-kind approver on tie-break', async () => {
await mountMockAdapter('telegram');
await mountMockAdapter('discord', async (h) => `dm-${h}`);
seedUser('telegram:111', 'telegram');
seedUser('discord:222', 'discord');
const result = await pickApprovalDelivery(['telegram:111', 'discord:222'], 'discord');
expect(result?.userId).toBe('discord:222');
});
it('falls through to any reachable approver when none match origin', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:111', 'telegram');
const result = await pickApprovalDelivery(['telegram:111'], 'discord');
expect(result?.userId).toBe('telegram:111');
});
it('returns null when nobody is reachable', async () => {
seedUser('telegram:111', 'telegram');
expect(await pickApprovalDelivery(['telegram:111'], 'telegram')).toBeNull();
});
});