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:
141
src/modules/approvals/picks.test.ts
Normal file
141
src/modules/approvals/picks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user