mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-15 19:06:18 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d08ecd57d | |||
| a30547fb6a | |||
| 27de55647b | |||
| 6cd15c6d13 |
@@ -109,7 +109,7 @@ ncl help
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
|
||||
| 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) |
|
||||
| approvals | list, get, approve, reject, reject-with-reason | Pending approval requests; `approve`/`reject`/`reject-with-reason` resolve one from the host CLI (operator-only, `--as-user <approver>`) via the same auth + resolution as a channel button |
|
||||
|
||||
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.45",
|
||||
"version": "2.1.46",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -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="225k tokens, 113% of context window">
|
||||
<title>225k tokens, 113% 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="232k tokens, 116% of context window">
|
||||
<title>232k tokens, 116% 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">225k</text>
|
||||
<text x="71" y="14">225k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">232k</text>
|
||||
<text x="71" y="14">232k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Operator approval-resolution verbs (`ncl approvals approve|reject|
|
||||
* reject-with-reason`). Drives `resolveApprovalFromCli` directly with a
|
||||
* fabricated caller context + seeded DB state, asserting the host-only guard,
|
||||
* the authorization check, and that each decision routes to the real
|
||||
* resolution (handler run on approve; row consumed; reason relayed).
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAgentGroup } from '../../db/agent-groups.js';
|
||||
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
|
||||
import { createPendingApproval, createSession, getPendingApproval } from '../../db/sessions.js';
|
||||
import { registerApprovalHandler } from '../../modules/approvals/primitive.js';
|
||||
import { grantRole } from '../../modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../../modules/permissions/db/users.js';
|
||||
import { initSessionFolder } from '../../session-manager.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import { resolveApprovalFromCli } from './approvals-resolve.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approvals-resolve' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-approvals-resolve';
|
||||
const HOST: CallerContext = { caller: 'host' };
|
||||
const AGENT: CallerContext = { caller: 'agent', sessionId: 'sess-1', agentGroupId: 'ag-1', messagingGroupId: 'mg-1' };
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
function seedApproval(approvalId: string, action: string): void {
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: 'sess-1',
|
||||
request_id: approvalId,
|
||||
action,
|
||||
payload: JSON.stringify({}),
|
||||
created_at: now(),
|
||||
title: 'Test approval',
|
||||
options_json: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: now(),
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder('ag-1', 'sess-1');
|
||||
|
||||
upsertUser({ id: 'cli:owner', kind: 'cli', display_name: 'Owner', created_at: now() });
|
||||
grantRole({ user_id: 'cli:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
upsertUser({ id: 'cli:stranger', kind: 'cli', display_name: 'Stranger', created_at: now() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveApprovalFromCli — operator approval resolution', () => {
|
||||
it('rejects an agent caller (self-approval guard) and leaves the row', async () => {
|
||||
seedApproval('appr-1', 'cli_command');
|
||||
await expect(resolveApprovalFromCli({ id: 'appr-1', as_user: 'cli:owner' }, AGENT, 'approve')).rejects.toThrow(
|
||||
/operator \(host\)/,
|
||||
);
|
||||
expect(getPendingApproval('appr-1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('errors on a missing approval', async () => {
|
||||
await expect(resolveApprovalFromCli({ id: 'nope', as_user: 'cli:owner' }, HOST, 'approve')).rejects.toThrow(
|
||||
/no pending approval/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unauthorized approver and leaves the row', async () => {
|
||||
seedApproval('appr-2', 'cli_command');
|
||||
await expect(resolveApprovalFromCli({ id: 'appr-2', as_user: 'cli:stranger' }, HOST, 'approve')).rejects.toThrow(
|
||||
/not authorized/,
|
||||
);
|
||||
expect(getPendingApproval('appr-2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires --id and --as-user', async () => {
|
||||
await expect(resolveApprovalFromCli({ as_user: 'cli:owner' }, HOST, 'approve')).rejects.toThrow(/--id is required/);
|
||||
await expect(resolveApprovalFromCli({ id: 'x' }, HOST, 'approve')).rejects.toThrow(/--as-user is required/);
|
||||
});
|
||||
|
||||
it('approve runs the registered action handler and consumes the row', async () => {
|
||||
const calls: string[] = [];
|
||||
registerApprovalHandler('cli_test_approve', async () => {
|
||||
calls.push('ran');
|
||||
});
|
||||
seedApproval('appr-3', 'cli_test_approve');
|
||||
|
||||
const res = await resolveApprovalFromCli({ id: 'appr-3', as_user: 'cli:owner' }, HOST, 'approve');
|
||||
|
||||
expect(calls).toEqual(['ran']);
|
||||
expect(res).toMatchObject({ approval_id: 'appr-3', action: 'cli_test_approve', resolved: 'approve' });
|
||||
expect(getPendingApproval('appr-3')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reject consumes the row without running the action handler', async () => {
|
||||
const calls: string[] = [];
|
||||
registerApprovalHandler('cli_test_reject', async () => {
|
||||
calls.push('ran');
|
||||
});
|
||||
seedApproval('appr-4', 'cli_test_reject');
|
||||
|
||||
const res = await resolveApprovalFromCli({ id: 'appr-4', as_user: 'cli:owner' }, HOST, 'reject');
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect(res.resolved).toBe('reject');
|
||||
expect(getPendingApproval('appr-4')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reject-with-reason consumes the row and carries the trimmed reason', async () => {
|
||||
seedApproval('appr-5', 'cli_command');
|
||||
|
||||
const res = await resolveApprovalFromCli(
|
||||
{ id: 'appr-5', as_user: 'cli:owner', reason: ' not this quarter ' },
|
||||
HOST,
|
||||
'reject-with-reason',
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({ approval_id: 'appr-5', resolved: 'reject', reason: 'not this quarter' });
|
||||
expect(getPendingApproval('appr-5')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reject-with-reason requires a reason and leaves the row on omission', async () => {
|
||||
seedApproval('appr-6', 'cli_command');
|
||||
await expect(
|
||||
resolveApprovalFromCli({ id: 'appr-6', as_user: 'cli:owner' }, HOST, 'reject-with-reason'),
|
||||
).rejects.toThrow(/--reason is required/);
|
||||
expect(getPendingApproval('appr-6')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('namespaces a bare --as-user against the cli channel', async () => {
|
||||
seedApproval('appr-7', 'cli_command');
|
||||
// 'owner' → 'cli:owner', the seeded owner
|
||||
const res = await resolveApprovalFromCli({ id: 'appr-7', as_user: 'owner' }, HOST, 'reject');
|
||||
expect(res.resolved).toBe('reject');
|
||||
expect(getPendingApproval('appr-7')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Operator-side approval resolution — the host-CLI equivalent of a channel
|
||||
* button click.
|
||||
*
|
||||
* `ncl approvals approve|reject|reject-with-reason` routes through the SAME
|
||||
* authorization (`isAuthorizedApprovalClick`) and resolution the click path
|
||||
* uses — `handleApprovalsResponse` for approve/plain-reject, `finalizeReject`
|
||||
* with an inline reason for reject-with-reason (the operator supplies the reason
|
||||
* directly, so the DM prompt-and-capture flow is skipped). Only the ingress
|
||||
* differs from a rendered card button.
|
||||
*
|
||||
* Host-only: an agent can never resolve an approval — that would let it
|
||||
* self-approve its own held action. `--as-user` is the approver identity the
|
||||
* operator resolves as, checked against the pending row exactly as a click's
|
||||
* user id would be. `--as-user` is asserted, not authenticated — which is sound
|
||||
* because the ncl socket is owner-only and a host caller already bypasses the
|
||||
* approval gate outright.
|
||||
*/
|
||||
import { getPendingApproval, getSession } from '../../db/sessions.js';
|
||||
import { finalizeReject } from '../../modules/approvals/finalize.js';
|
||||
import { handleApprovalsResponse, isAuthorizedApprovalClick } from '../../modules/approvals/response-handler.js';
|
||||
import type { ResponsePayload } from '../../response-registry.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
|
||||
export type ResolveDecision = 'approve' | 'reject' | 'reject-with-reason';
|
||||
|
||||
export interface ResolveResult {
|
||||
approval_id: string;
|
||||
action: string;
|
||||
resolved: 'approve' | 'reject';
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Matches reason-capture's cap so an operator reason and a chat reason relay identically. */
|
||||
const MAX_REASON_LEN = 280;
|
||||
|
||||
function clampReason(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length <= MAX_REASON_LEN ? trimmed : trimmed.slice(0, MAX_REASON_LEN - 1) + '…';
|
||||
}
|
||||
|
||||
export async function resolveApprovalFromCli(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
decision: ResolveDecision,
|
||||
): Promise<ResolveResult> {
|
||||
if (ctx.caller !== 'host') {
|
||||
throw new Error('approvals can only be resolved by an operator (host), not an agent');
|
||||
}
|
||||
|
||||
const id = String(args.id ?? '').trim();
|
||||
const asUserRaw = String(args.as_user ?? '').trim();
|
||||
if (!id) throw new Error('--id is required');
|
||||
if (!asUserRaw) throw new Error('--as-user is required (the approver identity, e.g. cli:local)');
|
||||
const approver = asUserRaw.includes(':') ? asUserRaw : `cli:${asUserRaw}`;
|
||||
|
||||
const approval = getPendingApproval(id);
|
||||
if (!approval) throw new Error(`no pending approval: ${id}`);
|
||||
|
||||
const payload: ResponsePayload = {
|
||||
questionId: id,
|
||||
value: decision === 'approve' ? 'approve' : 'reject',
|
||||
userId: approver,
|
||||
channelType: 'cli',
|
||||
platformId: '',
|
||||
threadId: null,
|
||||
};
|
||||
if (!isAuthorizedApprovalClick(approval, payload)) {
|
||||
throw new Error(
|
||||
`${approver} is not authorized to resolve approval ${id} — must be its named approver, or an admin/owner of the requesting agent group`,
|
||||
);
|
||||
}
|
||||
|
||||
if (decision === 'reject-with-reason') {
|
||||
const reason = clampReason(String(args.reason ?? ''));
|
||||
if (!reason) throw new Error('--reason is required for reject-with-reason');
|
||||
if (!approval.session_id) throw new Error(`approval ${id} has no session to notify`);
|
||||
const session = getSession(approval.session_id);
|
||||
if (!session) throw new Error(`approval ${id} session not found`);
|
||||
await finalizeReject(approval, session, approver, reason);
|
||||
return { approval_id: id, action: approval.action, resolved: 'reject', reason };
|
||||
}
|
||||
|
||||
// approve / plain reject → the same path a card click drives (handler +
|
||||
// grant-carrying replay on approve; finalizeReject on plain reject).
|
||||
await handleApprovalsResponse(payload);
|
||||
return { approval_id: id, action: approval.action, resolved: decision === 'approve' ? 'approve' : 'reject' };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { registerResource } from '../crud.js';
|
||||
import { resolveApprovalFromCli } from './approvals-resolve.js';
|
||||
|
||||
registerResource({
|
||||
name: 'approval',
|
||||
@@ -50,4 +51,74 @@ registerResource({
|
||||
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
|
||||
],
|
||||
operations: { list: 'open', get: 'open' },
|
||||
// Operator resolution verbs — the host-CLI equivalent of a channel button
|
||||
// click. Host-only (enforced in resolveApprovalFromCli); an agent can never
|
||||
// resolve an approval. Same auth + resolution as a real click.
|
||||
customOperations: {
|
||||
approve: {
|
||||
access: 'open',
|
||||
description:
|
||||
'Approve a pending approval and run its action (operator only). Runs the same authorization and resolution as a channel button click.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
|
||||
{
|
||||
name: 'as_user',
|
||||
type: 'string',
|
||||
description:
|
||||
'Approver identity to resolve as — a namespaced user id (e.g. cli:local). Must be an authorized approver of the row.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
examples: ['ncl approvals approve --id appr-… --as-user cli:local'],
|
||||
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'approve'),
|
||||
formatHuman: (data) => {
|
||||
const r = data as { action: string; approval_id: string };
|
||||
return `Approved ${r.action} (${r.approval_id}) and ran its action.`;
|
||||
},
|
||||
},
|
||||
reject: {
|
||||
access: 'open',
|
||||
description: 'Reject a pending approval (operator only).',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
|
||||
{
|
||||
name: 'as_user',
|
||||
type: 'string',
|
||||
description: 'Approver identity to resolve as (e.g. cli:local). Must be authorized.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
examples: ['ncl approvals reject --id appr-… --as-user cli:local'],
|
||||
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'reject'),
|
||||
formatHuman: (data) => {
|
||||
const r = data as { action: string; approval_id: string };
|
||||
return `Rejected ${r.action} (${r.approval_id}).`;
|
||||
},
|
||||
},
|
||||
'reject-with-reason': {
|
||||
access: 'open',
|
||||
description: 'Reject a pending approval and relay a one-line reason to the requesting agent (operator only).',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
|
||||
{
|
||||
name: 'as_user',
|
||||
type: 'string',
|
||||
description: 'Approver identity to resolve as (e.g. cli:local). Must be authorized.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'reason',
|
||||
type: 'string',
|
||||
description: 'One-line reason relayed to the agent (trimmed to 280 chars).',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
examples: ['ncl approvals reject-with-reason --id appr-… --as-user cli:local --reason "not this quarter"'],
|
||||
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'reject-with-reason'),
|
||||
formatHuman: (data) => {
|
||||
const r = data as { action: string; approval_id: string };
|
||||
return `Rejected ${r.action} (${r.approval_id}) with reason.`;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ function namespacedUserId(payload: ResponsePayload): string | null {
|
||||
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
|
||||
}
|
||||
|
||||
function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
|
||||
export function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
|
||||
const userId = namespacedUserId(payload);
|
||||
if (!userId) return false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user