mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31cc35ea32 | |||
| bf0a3d2612 | |||
| 6dc25a9b8a | |||
| cafba7fb18 | |||
| 10d400ec64 | |||
| 1a9643cfa3 | |||
| 2ba2555032 | |||
| 687d7d13ac | |||
| f094f56bf6 | |||
| fcee39ea14 | |||
| 3731e26769 |
@@ -179,61 +179,13 @@ const preToolUseHook: HookCallback = async (input) => {
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* OneCLI reject-with-reason handoff: when an admin rejects a gateway request
|
||||
* with a reason, the host drops it at /workspace/onecli-rejection.json BEFORE
|
||||
* releasing the deny, so the failed tool call and the reason arrive as one
|
||||
* event. Consume the file (single use) and hand the reason to the model as
|
||||
* additional context on this tool result. Freshness-gated: a stale file from
|
||||
* an interrupted run must not annotate an unrelated failure.
|
||||
*/
|
||||
const ONECLI_REJECTION_FILE = '/workspace/onecli-rejection.json';
|
||||
const ONECLI_REJECTION_FRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
function consumeOneCLIRejection(input: unknown): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(ONECLI_REJECTION_FILE)) return null;
|
||||
const info = JSON.parse(fs.readFileSync(ONECLI_REJECTION_FILE, 'utf8')) as {
|
||||
rejectedAt?: string;
|
||||
reason?: string;
|
||||
host?: string | null;
|
||||
};
|
||||
// The rejection annotates a FAILED gateway call — require failure-shaped
|
||||
// output (or the target host) in this tool result before consuming.
|
||||
const resp = JSON.stringify((input as { tool_response?: unknown }).tool_response ?? '');
|
||||
const looksRelated =
|
||||
/denied|reject|403|approval|forbidden/i.test(resp) || (info.host ? resp.includes(info.host) : false);
|
||||
if (!looksRelated) return null;
|
||||
fs.unlinkSync(ONECLI_REJECTION_FILE);
|
||||
if (!info.reason || Date.now() - new Date(info.rejectedAt ?? 0).getTime() > ONECLI_REJECTION_FRESH_MS) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
`This request was rejected by the admin with the following reason: "${info.reason}". ` +
|
||||
'Do not retry the same request as-is — address the reason first (revise and re-attempt, or explain to the user).'
|
||||
);
|
||||
} catch (err) {
|
||||
log(`PostToolUse: rejection-file check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear in-flight tool on PostToolUse / PostToolUseFailure; inject a held
|
||||
* OneCLI rejection reason into the failed call's context when present. */
|
||||
const postToolUseHook: HookCallback = async (input) => {
|
||||
/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */
|
||||
const postToolUseHook: HookCallback = async () => {
|
||||
try {
|
||||
clearContainerToolInFlight();
|
||||
} catch (err) {
|
||||
log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
const rejection = consumeOneCLIRejection(input);
|
||||
if (rejection) {
|
||||
log('PostToolUse: injected OneCLI rejection reason into tool response context');
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: rejection },
|
||||
} as unknown as Awaited<ReturnType<HookCallback>>;
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.26",
|
||||
"version": "2.1.29",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -185,6 +185,16 @@ register({
|
||||
handler: async (args) => ({ id: (args as Record<string, unknown>).id, agent_group_id: 'g1' }),
|
||||
});
|
||||
|
||||
// Echoes args back — used to assert dash-joined positional id resolution.
|
||||
register({
|
||||
name: 'groups-get',
|
||||
description: 'test command (groups get)',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
import { dispatch } from './dispatch.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
@@ -584,3 +594,19 @@ describe('CLI scope enforcement', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Dash-joined positional id resolution (generated ids contain dashes) ---
|
||||
|
||||
describe('dash-joined positional id resolution', () => {
|
||||
it('resolves `groups-get-<uuid-with-dashes>` to (groups get, id=<uuid>)', async () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
const resp = await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe(uuid);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+13
-8
@@ -26,19 +26,24 @@ export async function dispatch(
|
||||
): Promise<ResponseFrame> {
|
||||
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").
|
||||
// Fallback: if the full command isn't registered, split the dash-joined
|
||||
// command and treat the longest registered prefix as the command, with the
|
||||
// re-joined remainder as the target ID. Clients join all positional args
|
||||
// with dashes (e.g. `ncl groups get abc123` → command "groups-get-abc123"),
|
||||
// and generated ids (UUIDs, `sess-…`, `appr-…`) themselves contain dashes,
|
||||
// so trimming a single trailing segment isn't enough — walk prefixes from
|
||||
// longest to shortest so `groups-get-<uuid-with-dashes>` still resolves to
|
||||
// "groups-get" + id "<uuid-with-dashes>".
|
||||
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 parts = req.command.split('-');
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const shortened = parts.slice(0, i).join('-');
|
||||
const fallback = lookup(shortened);
|
||||
if (fallback) {
|
||||
const tail = parts.slice(i).join('-');
|
||||
cmd = fallback;
|
||||
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Tests for the host-side command gate — filtered commands are dropped
|
||||
* before reaching the container, and admin commands are gated against
|
||||
* the user_roles table.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { gateCommand } from './command-gate.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { createUser } from './modules/permissions/db/users.js';
|
||||
import { grantRole } from './modules/permissions/db/user-roles.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function seedAgentGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: now() });
|
||||
}
|
||||
|
||||
function seedUser(id: string): void {
|
||||
createUser({ id, kind: 'telegram', display_name: null, created_at: now() });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
seedAgentGroup('ag-1');
|
||||
seedAgentGroup('ag-2');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('filtered commands', () => {
|
||||
it('drops /start before it reaches the container', () => {
|
||||
expect(gateCommand('/start', 'telegram:1', 'ag-1')).toEqual({ action: 'filter' });
|
||||
});
|
||||
|
||||
it('drops /start regardless of sender', () => {
|
||||
expect(gateCommand('/start', null, 'ag-1')).toEqual({ action: 'filter' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin gating goes through roles', () => {
|
||||
it('denies an admin command from a non-admin user', () => {
|
||||
expect(gateCommand('/clear', 'telegram:nobody', 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
|
||||
});
|
||||
|
||||
it('denies an admin command with no sender', () => {
|
||||
expect(gateCommand('/clear', null, 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
|
||||
});
|
||||
|
||||
it('allows an admin command from an owner', () => {
|
||||
seedUser('telegram:owner');
|
||||
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
expect(gateCommand('/clear', 'telegram:owner', 'ag-1')).toEqual({ action: 'pass' });
|
||||
});
|
||||
|
||||
it('allows an admin command from a scoped admin of the group', () => {
|
||||
seedUser('telegram:admin');
|
||||
grantRole({
|
||||
user_id: 'telegram:admin',
|
||||
role: 'admin',
|
||||
agent_group_id: 'ag-1',
|
||||
granted_by: null,
|
||||
granted_at: now(),
|
||||
});
|
||||
expect(gateCommand('/clear', 'telegram:admin', 'ag-1')).toEqual({ action: 'pass' });
|
||||
expect(gateCommand('/clear', 'telegram:admin', 'ag-2')).toEqual({ action: 'deny', command: '/clear' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normal messages pass through', () => {
|
||||
it('passes a plain message', () => {
|
||||
expect(gateCommand('hello there', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
|
||||
});
|
||||
|
||||
it('passes an unknown slash command', () => {
|
||||
expect(gateCommand('/whatever', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
|
||||
});
|
||||
});
|
||||
+3
-14
@@ -7,11 +7,11 @@
|
||||
* "Permission denied" response written directly to messages_out
|
||||
* - Normal messages: pass through unchanged
|
||||
*/
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { hasAdminPrivilege } from './modules/permissions/db/user-roles.js';
|
||||
|
||||
export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string };
|
||||
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const FILTERED_COMMANDS = new Set(['/start', '/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
|
||||
|
||||
/**
|
||||
@@ -48,16 +48,5 @@ export function gateCommand(content: string, userId: string | null, agentGroupId
|
||||
|
||||
function isAdmin(userId: string | null, agentGroupId: string): boolean {
|
||||
if (!userId) return false;
|
||||
if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND (agent_group_id IS NULL OR agent_group_id = ?)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId, agentGroupId);
|
||||
return row != null;
|
||||
return hasAdminPrivilege(userId, agentGroupId);
|
||||
}
|
||||
|
||||
@@ -32,19 +32,9 @@ export async function finalizeReject(
|
||||
userId: string,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
// 'onecli_credential' is an internal action key — describe the actual
|
||||
// request (host from the persisted payload) to the agent instead.
|
||||
let what = `${approval.action} request`;
|
||||
if (approval.action === 'onecli_credential') {
|
||||
let host: string | undefined;
|
||||
try {
|
||||
host = (JSON.parse(approval.payload ?? '{}') as { host?: string }).host;
|
||||
} catch {
|
||||
/* keep generic */
|
||||
}
|
||||
what = host ? `credential request to ${host}` : 'credential request';
|
||||
}
|
||||
const text = reason ? `Your ${what} was rejected by admin: "${reason}"` : `Your ${what} was rejected by admin.`;
|
||||
const text = reason
|
||||
? `Your ${approval.action} request was rejected by admin: "${reason}"`
|
||||
: `Your ${approval.action} request was rejected by admin.`;
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
|
||||
@@ -17,20 +17,9 @@
|
||||
* Startup sweep edits any leftover cards from a previous process to
|
||||
* "Expired (host restarted)" and drops the rows.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { OneCLI, type ApprovalRequest, type ManualApprovalHandle } from '@onecli-sh/sdk';
|
||||
|
||||
import { finalizeReject } from './finalize.js';
|
||||
import {
|
||||
notifyApprovalResolved,
|
||||
pickApprovalDelivery,
|
||||
pickApprover,
|
||||
registerApprovalResolvedHandler,
|
||||
registerReasonRejectFinalizer,
|
||||
REJECT_WITH_REASON_VALUE,
|
||||
} from './primitive.js';
|
||||
import { pickApprovalDelivery, pickApprover } from './primitive.js';
|
||||
import { ONECLI_API_KEY, ONECLI_URL } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import {
|
||||
@@ -41,8 +30,7 @@ import {
|
||||
} from '../../db/sessions.js';
|
||||
import type { ChannelDeliveryAdapter } from '../../delivery.js';
|
||||
import { log } from '../../log.js';
|
||||
import { sessionDir } from '../../session-manager.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
|
||||
export const ONECLI_ACTION = 'onecli_credential';
|
||||
|
||||
@@ -53,9 +41,6 @@ const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY });
|
||||
interface PendingState {
|
||||
resolve: (decision: Decision) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
/** Reject-with-reason: the gateway decision is held open until the reason
|
||||
* is captured and relayed (or the gateway TTL forces the deny out). */
|
||||
heldForReason?: boolean;
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingState>();
|
||||
@@ -97,101 +82,10 @@ export function resolveOneCLIApproval(approvalId: string, selectedOption: string
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject-with-reason: keep the gateway decision UNRESOLVED while the reason is
|
||||
* being captured, so the agent's held HTTP call fails only after the reason is
|
||||
* available to it. The row stays for reason-capture; the TTL timer stays armed
|
||||
* as the safety valve — if the approver types slower than the gateway TTL, the
|
||||
* deny goes out then and a late reason still relays through the
|
||||
* awaiting_reason row.
|
||||
*/
|
||||
export function holdOneCLIApprovalForReason(approvalId: string): boolean {
|
||||
const state = pending.get(approvalId);
|
||||
if (!state) return false;
|
||||
state.heldForReason = true;
|
||||
log.info('OneCLI approval held awaiting rejection reason', { approvalId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom reason delivery for OneCLI rejects (registered with reason-capture's
|
||||
* finalizer registry): drop the reason where the agent-runner's PostToolUse
|
||||
* hook can inject it into the FAILED TOOL CALL itself — the session workspace,
|
||||
* mounted at /workspace in the container — then release the held deny. The
|
||||
* agent experiences one coherent event: its gmail call fails carrying the
|
||||
* admin's reason, instead of a bare denial plus a separate chat message.
|
||||
*
|
||||
* Falls back to the chat-message relay when the decision is no longer held
|
||||
* (gateway TTL beat the approver, or a restart lost the resolver) — the tool
|
||||
* call already failed bare, so a session message is the only remaining path.
|
||||
*/
|
||||
async function finalizeOneCLIReasonReject(
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
const state = pending.get(approval.approval_id);
|
||||
if (!state || !reason) {
|
||||
await finalizeReject(approval, session, userId, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
let requestMeta: { host?: string; method?: string; path?: string } = {};
|
||||
try {
|
||||
requestMeta = JSON.parse(approval.payload ?? '{}') as typeof requestMeta;
|
||||
} catch {
|
||||
/* best-effort metadata */
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(sessionDir(session.agent_group_id, session.id), 'onecli-rejection.json'),
|
||||
JSON.stringify({
|
||||
rejectedAt: new Date().toISOString(),
|
||||
reason,
|
||||
host: requestMeta.host ?? null,
|
||||
method: requestMeta.method ?? null,
|
||||
path: requestMeta.path ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
pending.delete(approval.approval_id);
|
||||
clearTimeout(state.timer);
|
||||
updatePendingApprovalStatus(approval.approval_id, 'rejected');
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await notifyApprovalResolved({ approval, session, outcome: 'reject', userId });
|
||||
// Release the deny LAST — the reason file is already in place when the
|
||||
// gateway fails the agent's held HTTP call.
|
||||
state.resolve('deny');
|
||||
log.info('OneCLI approval denied with reason injected into tool response', {
|
||||
approvalId: approval.approval_id,
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
|
||||
registerReasonRejectFinalizer(ONECLI_ACTION, finalizeOneCLIReasonReject);
|
||||
|
||||
let resolvedHandlerRegistered = false;
|
||||
|
||||
export function startOneCLIApprovalHandler(deliveryAdapter: ChannelDeliveryAdapter): void {
|
||||
if (handle) return;
|
||||
adapterRef = deliveryAdapter;
|
||||
|
||||
// Releases a held reject-with-reason decision once finalizeReject has
|
||||
// relayed the reason (or the sweep gave up on the approver) — the reason
|
||||
// reaches the agent's session BEFORE its held HTTP call fails.
|
||||
if (!resolvedHandlerRegistered) {
|
||||
resolvedHandlerRegistered = true;
|
||||
registerApprovalResolvedHandler(async (event) => {
|
||||
if (event.approval.action !== ONECLI_ACTION) return;
|
||||
const state = pending.get(event.approval.approval_id);
|
||||
if (!state) return; // TTL already released the deny — nothing held
|
||||
pending.delete(event.approval.approval_id);
|
||||
clearTimeout(state.timer);
|
||||
state.resolve('deny');
|
||||
log.info('OneCLI approval denied after reason relay', { approvalId: event.approval.approval_id });
|
||||
});
|
||||
}
|
||||
|
||||
// Sweep any rows left over from a previous process.
|
||||
sweepStaleApprovals().catch((err) => log.error('OneCLI approval sweep failed', { err }));
|
||||
|
||||
@@ -255,7 +149,6 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
||||
const onecliOptions = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected — reason requested', value: REJECT_WITH_REASON_VALUE },
|
||||
];
|
||||
let platformMessageId: string | undefined;
|
||||
try {
|
||||
@@ -309,17 +202,8 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
||||
|
||||
return new Promise<Decision>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
const state = pending.get(approvalId);
|
||||
if (!state) return;
|
||||
if (!pending.has(approvalId)) return;
|
||||
pending.delete(approvalId);
|
||||
if (state.heldForReason) {
|
||||
// Gateway TTL caught up while the approver was still typing the
|
||||
// reason. Release the deny; keep the awaiting_reason row so the late
|
||||
// reason (or the ghost sweep) still relays to the agent.
|
||||
log.info('OneCLI approval TTL reached while awaiting reason — denying now', { approvalId });
|
||||
resolve('deny');
|
||||
return;
|
||||
}
|
||||
expireApproval(approvalId, 'no response').catch((err) =>
|
||||
log.error('Failed to mark OneCLI approval expired', { approvalId, err }),
|
||||
);
|
||||
@@ -370,16 +254,46 @@ async function sweepStaleApprovals(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The hosted gateway's structured request summary — not yet in the SDK's
|
||||
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
|
||||
* performed plus labeled fields (To / Subject / Body for email sends). */
|
||||
interface ApprovalSummary {
|
||||
action?: string;
|
||||
details?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
const SUMMARY_VALUE_EXCERPT_CHARS = 900;
|
||||
|
||||
function buildQuestion(request: ApprovalRequest, agentName: string): string {
|
||||
const lines = [
|
||||
'Credential access request',
|
||||
`Agent: ${agentName}`,
|
||||
'```',
|
||||
`${request.method} ${request.host}${request.path}`,
|
||||
'```',
|
||||
];
|
||||
if (request.bodyPreview) {
|
||||
lines.push('Body:', '```', request.bodyPreview, '```');
|
||||
const lines = [`*Agent:* ${agentName}`];
|
||||
|
||||
const summary = (request as ApprovalRequest & { summary?: ApprovalSummary }).summary;
|
||||
if (summary?.details?.length) {
|
||||
if (summary.action) lines.push(`*Action:* ${summary.action}`);
|
||||
// A render bug here must never decide the request: handleRequest's catch
|
||||
// returns 'deny', so stay defensive — coerce non-string values instead of
|
||||
// assuming the gateway's shape, and keep the card under Slack's 3000-char
|
||||
// section limit or delivery itself fails.
|
||||
let budget = 2600;
|
||||
for (const { label, value } of summary.details) {
|
||||
const raw = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
|
||||
const cap = Math.min(SUMMARY_VALUE_EXCERPT_CHARS, Math.max(0, budget));
|
||||
if (cap === 0) {
|
||||
lines.push(`_…${summary.details.length} field(s) omitted for length — see the audit payload._`);
|
||||
break;
|
||||
}
|
||||
const v = raw.length > cap ? `${raw.slice(0, cap)}…` : raw;
|
||||
budget -= v.length + String(label).length + 8;
|
||||
// Multi-line values (message bodies) read better fenced; short labeled
|
||||
// fields (To, Subject) inline.
|
||||
if (v.includes('\n')) lines.push(`*${label}:*`, '```', v, '```');
|
||||
else lines.push(`*${label}:* ${v}`);
|
||||
}
|
||||
} else if (request.bodyPreview) {
|
||||
lines.push('```', request.bodyPreview.slice(0, SUMMARY_VALUE_EXCERPT_CHARS * 2), '```');
|
||||
lines.push(`_${request.method} ${request.host}${request.path}_`);
|
||||
} else {
|
||||
lines.push(`_${request.method} ${request.host}${request.path}_`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -107,29 +107,6 @@ export function registerApprovalResolvedHandler(handler: ApprovalResolvedHandler
|
||||
approvalResolvedHandlers.push(handler);
|
||||
}
|
||||
|
||||
// ── Reason-reject finalizer overrides ──
|
||||
// By default a captured rejection reason is relayed as a session chat message
|
||||
// (finalizeReject). An action can register a custom finalizer to deliver the
|
||||
// reason differently — e.g. OneCLI credential rejects inject it into the
|
||||
// agent's failed tool call instead of a separate message.
|
||||
|
||||
export type ReasonRejectFinalizer = (
|
||||
approval: PendingApproval,
|
||||
session: Session,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
) => Promise<void>;
|
||||
|
||||
const reasonRejectFinalizers = new Map<string, ReasonRejectFinalizer>();
|
||||
|
||||
export function registerReasonRejectFinalizer(action: string, finalizer: ReasonRejectFinalizer): void {
|
||||
reasonRejectFinalizers.set(action, finalizer);
|
||||
}
|
||||
|
||||
export function getReasonRejectFinalizer(action: string): ReasonRejectFinalizer | undefined {
|
||||
return reasonRejectFinalizers.get(action);
|
||||
}
|
||||
|
||||
/** Fire every registered approval-resolved callback. Called by the response handler. */
|
||||
export async function notifyApprovalResolved(event: ApprovalResolvedEvent): Promise<void> {
|
||||
for (const handler of approvalResolvedHandlers) {
|
||||
|
||||
@@ -22,7 +22,6 @@ import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import {
|
||||
deletePendingApproval,
|
||||
findSessionByAgentGroup,
|
||||
getExpiredAwaitingReasonApprovals,
|
||||
getPendingApproval,
|
||||
getSession,
|
||||
@@ -33,7 +32,6 @@ import { registerMessageInterceptor } from '../../router.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import { ensureUserDm } from '../permissions/user-dm.js';
|
||||
import { finalizeReject } from './finalize.js';
|
||||
import { getReasonRejectFinalizer } from './primitive.js';
|
||||
|
||||
/** How long an awaiting-reason hold waits for the admin's reply before the sweep finalizes a plain reject. */
|
||||
const REASON_CAPTURE_WINDOW_MS = 5 * 60 * 1000;
|
||||
@@ -138,21 +136,14 @@ export async function captureReasonReply(event: InboundEvent): Promise<boolean>
|
||||
return false;
|
||||
}
|
||||
|
||||
// OneCLI credential rows carry no session_id — fall back to the originating
|
||||
// agent group's active session so the reason still reaches the agent.
|
||||
const session =
|
||||
(approval.session_id ? getSession(approval.session_id) : null) ??
|
||||
(approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : null);
|
||||
const session = approval.session_id ? getSession(approval.session_id) : null;
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const reason = clampReason(extractText(event));
|
||||
// Action-specific delivery (e.g. OneCLI injects the reason into the failed
|
||||
// tool call); default is the chat-message relay.
|
||||
const finalizer = getReasonRejectFinalizer(approval.action) ?? finalizeReject;
|
||||
await finalizer(approval, session, arming.userId, reason || undefined);
|
||||
await finalizeReject(approval, session, arming.userId, reason || undefined);
|
||||
log.info('reject-with-reason: reason captured and relayed', {
|
||||
approvalId: approval.approval_id,
|
||||
hasReason: reason.length > 0,
|
||||
@@ -171,9 +162,7 @@ registerMessageInterceptor(captureReasonReply);
|
||||
export async function sweepAwaitingReasonRejects(): Promise<void> {
|
||||
const rows = getExpiredAwaitingReasonApprovals(new Date().toISOString());
|
||||
for (const approval of rows) {
|
||||
const session =
|
||||
(approval.session_id ? getSession(approval.session_id) : null) ??
|
||||
(approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : null);
|
||||
const session = approval.session_id ? getSession(approval.session_id) : null;
|
||||
if (!session) {
|
||||
deletePendingApproval(approval.approval_id);
|
||||
continue;
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
* core iterates handlers and the first one to return `true` claims the response.
|
||||
*/
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { deletePendingApproval, findSessionByAgentGroup, getPendingApproval, getSession } from '../../db/sessions.js';
|
||||
import { deletePendingApproval, getPendingApproval, getSession } from '../../db/sessions.js';
|
||||
import type { ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval } from '../../types.js';
|
||||
import { hasAdminPrivilege, isGlobalAdmin, isOwner } from '../permissions/db/user-roles.js';
|
||||
import { finalizeReject } from './finalize.js';
|
||||
import { holdOneCLIApprovalForReason, ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { ONECLI_ACTION, resolveOneCLIApproval } from './onecli-approvals.js';
|
||||
import { getApprovalHandler, notifyApprovalResolved, REJECT_WITH_REASON_VALUE } from './primitive.js';
|
||||
import { armReasonCapture } from './reason-capture.js';
|
||||
|
||||
@@ -42,28 +42,6 @@ export async function handleApprovalsResponse(payload: ResponsePayload): Promise
|
||||
}
|
||||
|
||||
if (approval.action === ONECLI_ACTION) {
|
||||
// "Reject with reason…" — the gateway decision is HELD open while the
|
||||
// reason is captured, so the agent's pending HTTP call fails only after
|
||||
// the reason is already relayed into its session (finalizeReject relays,
|
||||
// then the approval-resolved handler in onecli-approvals releases the
|
||||
// deny; the gateway TTL is the safety valve). OneCLI rows carry no
|
||||
// session_id; resolve the session via the originating agent group.
|
||||
if (payload.value === REJECT_WITH_REASON_VALUE) {
|
||||
const session = approval.agent_group_id ? findSessionByAgentGroup(approval.agent_group_id) : undefined;
|
||||
if (!session) {
|
||||
log.warn('OneCLI reject-with-reason: no active session to relay to — plain reject', {
|
||||
approvalId: approval.approval_id,
|
||||
agentGroupId: approval.agent_group_id,
|
||||
});
|
||||
if (!resolveOneCLIApproval(payload.questionId, 'reject')) deletePendingApproval(payload.questionId);
|
||||
return true;
|
||||
}
|
||||
// false ⇒ the resolver died with a previous process (the gateway side is
|
||||
// already settled) — the reason relay is still worth arming.
|
||||
holdOneCLIApprovalForReason(payload.questionId);
|
||||
await armReasonCapture(approval, session, namespacedUserId(payload) ?? '');
|
||||
return true;
|
||||
}
|
||||
if (resolveOneCLIApproval(payload.questionId, payload.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user