mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-15 19:06:18 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b508bb951e | |||
| 3f643201c6 | |||
| 9390037761 | |||
| 82d9171985 | |||
| ef5d09680a | |||
| 33b33b84b9 |
@@ -7,6 +7,34 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
|
||||
|
||||
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
|
||||
|
||||
## Number safety check (required)
|
||||
|
||||
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
|
||||
|
||||
Otherwise, use `AskUserQuestion`:
|
||||
|
||||
**Which WhatsApp number will NanoClaw use?**
|
||||
|
||||
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
|
||||
- **Shared / personal number** — the user's existing everyday WhatsApp number
|
||||
|
||||
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
|
||||
|
||||
If `NUMBER_MODE=shared`, show this warning exactly:
|
||||
|
||||
> ⚠️ **Risk to your WhatsApp account**
|
||||
>
|
||||
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
|
||||
>
|
||||
> We strongly recommend using a separate, dedicated number for NanoClaw.
|
||||
|
||||
Then use `AskUserQuestion`:
|
||||
|
||||
- **Go back and use a dedicated number (Recommended)**
|
||||
- **I understand the risk — continue with my shared number**
|
||||
|
||||
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
|
||||
@@ -92,7 +120,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
|
||||
Check if WhatsApp is already authenticated. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
@@ -209,9 +237,7 @@ The adapter behaves fundamentally differently depending on whether the linked nu
|
||||
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
|
||||
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
|
||||
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
|
||||
|
||||
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
|
||||
|
||||
@@ -224,7 +250,7 @@ ASSISTANT_HAS_OWN_NUMBER=false
|
||||
|
||||
### Update path: existing install, flag unset
|
||||
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Use the mode established by the required safety check and write it explicitly.
|
||||
|
||||
Suggest a default by comparing the authed number against the wired DM chat:
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded(<reason>)` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
|
||||
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.)
|
||||
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes one decision function — `guard()` in `src/guard/` — before it executes: `allow`, `hold` (the existing approval flows), or `deny`. Today's checks are preserved verbatim as each action's decision; ncl commands and delivery actions cannot register without a guard, and approved replays re-enter carrying the approval row as a **grant** (the forgeable `approved: true` boolean is deleted) with the checks re-run live. Two deliberate outcome changes: **(a)** approving a held a2a message after its destination was revoked no longer delivers it — the requester is told "approved, but not delivered" and the host logs a warning; **(b)** a forged, already-consumed, or mismatched grant refuses the replay instead of executing.
|
||||
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws instead of silently disarming the guard.
|
||||
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
|
||||
@@ -62,12 +62,12 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
|
||||
| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake |
|
||||
| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) |
|
||||
| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the precheck → guard → deny/hold/allow consult path for privileged delivery actions; the registry itself stays in `delivery.ts` |
|
||||
| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the guard-consult pipeline for privileged delivery actions (registry stays in `delivery.ts`) |
|
||||
| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence |
|
||||
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
|
||||
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
|
||||
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
|
||||
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded(<reason>)`); broadcast hooks stay plain registrations, and the privileged click path — the channel-registration card handler — consults the guard inline, like the a2a route and the unknown-sender gate (the free-text name reply is deliberately not re-authorized; the click is the auth, as on main). Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` |
|
||||
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) define each action's decision; ncl commands + delivery actions demand a guard at registration; approved replays carry the approval row as a grant and re-run the checks. Conformance test: `src/guard/conformance.test.ts` |
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
brightSelect: vi.fn(),
|
||||
note: vi.fn(),
|
||||
userInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bright-select.js', () => ({
|
||||
brightSelect: mocks.brightSelect,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/theme.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../lib/theme.js')>()),
|
||||
note: mocks.note,
|
||||
}));
|
||||
|
||||
vi.mock('../logs.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../logs.js')>()),
|
||||
userInput: mocks.userInput,
|
||||
}));
|
||||
|
||||
import { BACK_TO_CHANNEL_SELECTION } from '../lib/back-nav.js';
|
||||
import { runWhatsAppChannel } from './whatsapp.js';
|
||||
|
||||
describe('WhatsApp shared-number risk gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the warning and requires explicit acknowledgement for a shared number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('continue').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining('temporarily suspend or permanently ban that number'),
|
||||
'Risk to your WhatsApp account',
|
||||
);
|
||||
expect(mocks.userInput).toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', 'true');
|
||||
});
|
||||
|
||||
it('does not show the warning for a dedicated number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).not.toHaveBeenCalled();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
|
||||
it('switches to dedicated mode when the user declines the shared-number risk', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledOnce();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
});
|
||||
+29
-13
@@ -6,8 +6,8 @@
|
||||
*
|
||||
* 1. Ask whether the agent gets a dedicated number or shares the
|
||||
* operator's personal one. Personal ⇒ interception screen that spells
|
||||
* out self-chat-only mode and steers toward alternatives (default is
|
||||
* back to channel selection)
|
||||
* out the account-ban risk and self-chat-only mode (default is switching
|
||||
* to a dedicated number)
|
||||
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 3. If pairing-code: collect the phone number
|
||||
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
@@ -68,7 +68,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
let mode: 'dedicated' | 'shared' = ownership;
|
||||
if (mode === 'shared') {
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
if (proceed === 'dedicated') mode = 'dedicated';
|
||||
}
|
||||
|
||||
const method = await askAuthMethod();
|
||||
@@ -121,7 +121,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
// Chatting from the bot's own number IS the shared-number setup —
|
||||
// route through the same interception screen as the up-front pick.
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
if (proceed === 'dedicated') return BACK_TO_CHANNEL_SELECTION;
|
||||
mode = 'shared';
|
||||
}
|
||||
}
|
||||
@@ -221,13 +221,19 @@ async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interception screen for the shared-number path: make the self-chat-only
|
||||
* tradeoff explicit and steer toward alternatives before any install or
|
||||
* auth happens. Default is bailing back to channel selection.
|
||||
* Interception screen for the shared-number path: make the account-ban risk
|
||||
* and self-chat-only tradeoff explicit before any install or auth happens.
|
||||
* Default is switching to a dedicated number.
|
||||
*/
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
|
||||
note(
|
||||
[
|
||||
'Connecting your shared or personal number could cause WhatsApp to',
|
||||
'temporarily suspend or permanently ban that number. You could lose access',
|
||||
'to the WhatsApp account, chats, and groups you rely on.',
|
||||
'',
|
||||
'We strongly recommend using a separate, dedicated number for NanoClaw.',
|
||||
'',
|
||||
'On your personal number, the agent lives only in your "You" / self-chat.',
|
||||
'Messages other people send you are ignored entirely — never read, never',
|
||||
'answered, never flagged for approval. Nobody else can talk to the agent.',
|
||||
@@ -238,19 +244,29 @@ async function confirmSharedNumber(): Promise<'continue' | 'back'> {
|
||||
` • ${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
|
||||
` • ${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
|
||||
].join('\n'),
|
||||
'Personal number = self-chat only',
|
||||
'Risk to your WhatsApp account',
|
||||
);
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to proceed?',
|
||||
options: [
|
||||
{ value: 'back', label: '← Pick a different channel' },
|
||||
{ value: 'continue', label: 'Continue — self-chat only' },
|
||||
{
|
||||
value: 'dedicated',
|
||||
label: 'Go back and use a dedicated number',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'continue',
|
||||
label: 'I understand the risk — continue with my shared number',
|
||||
},
|
||||
],
|
||||
initialValue: 'back',
|
||||
initialValue: 'dedicated',
|
||||
}),
|
||||
) as 'continue' | 'back';
|
||||
) as 'continue' | 'dedicated';
|
||||
setupLog.userInput('whatsapp_shared_confirm', choice);
|
||||
if (choice === 'continue') {
|
||||
setupLog.userInput('whatsapp_shared_risk_acknowledged', 'true');
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
export {
|
||||
ALLOW,
|
||||
DENY,
|
||||
GuardDenyError,
|
||||
HOLD,
|
||||
isUnguarded,
|
||||
unguarded,
|
||||
|
||||
@@ -73,3 +73,16 @@ export const HOLD = (reason: string, approverUserId?: string): GuardDecision =>
|
||||
reason,
|
||||
approverUserId,
|
||||
});
|
||||
|
||||
/**
|
||||
* A guard deny travelling as an exception — for flows whose entry point
|
||||
* signals refusal by throwing (the a2a route). Catching it lets a caller
|
||||
* distinguish "the guard refused, as designed" (report to the requester,
|
||||
* log a warning) from a real runtime failure (crash path, stack trace).
|
||||
*/
|
||||
export class GuardDenyError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(reason);
|
||||
this.name = 'GuardDenyError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { guard } from '../../guard/index.js';
|
||||
import { GuardDenyError, guard } from '../../guard/index.js';
|
||||
import { log } from '../../log.js';
|
||||
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
@@ -255,7 +255,7 @@ export async function routeAgentMessage(
|
||||
});
|
||||
|
||||
if (decision.effect === 'deny') {
|
||||
throw new Error(decision.reason);
|
||||
throw new GuardDenyError(decision.reason);
|
||||
}
|
||||
|
||||
// Gated edge: hold the message and return (not throw) so the delivery loop
|
||||
|
||||
@@ -205,29 +205,33 @@ describe('agent message policies', () => {
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destination revoked between hold and approve → the approved replay is blocked', async () => {
|
||||
it('destination revoked between hold and approve → refused cleanly, requester told, nothing delivered', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
const payload = { id: 'held-2', platform_id: B, content: JSON.stringify({ text: 'stale' }), in_reply_to: null };
|
||||
const approval = seedA2aHold('appr-a2a-2', payload);
|
||||
|
||||
deleteDestination(A, 'b'); // revoke A→B while the card is pending
|
||||
|
||||
await expect(
|
||||
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
|
||||
).rejects.toThrow(/unauthorized agent-to-agent/);
|
||||
const notify = vi.fn();
|
||||
// An expected policy refusal — resolves (no throw), so the response
|
||||
// handler never records it as a handler crash.
|
||||
await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval });
|
||||
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringMatching(/not delivered.*no destination for/));
|
||||
});
|
||||
|
||||
it('mismatched grant (held for another target) refuses the replay', async () => {
|
||||
it('mismatched grant (held for another target) refuses the replay cleanly', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
// Grant was approved for a message to A (different target than the replay).
|
||||
const approval = seedA2aHold('appr-a2a-3', { id: 'other', platform_id: A, content: '{}', in_reply_to: null });
|
||||
const payload = { id: 'held-3', platform_id: B, content: JSON.stringify({ text: 'swap' }), in_reply_to: null };
|
||||
|
||||
await expect(
|
||||
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
|
||||
).rejects.toThrow(/invalid or mismatched grant/);
|
||||
const notify = vi.fn();
|
||||
await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval });
|
||||
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringMatching(/not delivered.*invalid or mismatched grant/));
|
||||
});
|
||||
|
||||
it('a grant only works while its row is live (executes once)', async () => {
|
||||
@@ -237,10 +241,11 @@ describe('agent message policies', () => {
|
||||
|
||||
deletePendingApproval(approval.approval_id); // resolution already consumed the row
|
||||
|
||||
await expect(
|
||||
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
|
||||
).rejects.toThrow(/invalid or mismatched grant/);
|
||||
const notify = vi.fn();
|
||||
await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval });
|
||||
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringMatching(/not delivered.*invalid or mismatched grant/));
|
||||
});
|
||||
|
||||
// ── ghost-gate cleanup ──
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Approve handler for a held a2a message. (Reject is handled by the generic response-handler path.) */
|
||||
import { GuardDenyError } from '../../guard/index.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { ApprovalHandler } from '../approvals/index.js';
|
||||
import { routeAgentMessage, type RoutableAgentMessage } from './agent-route.js';
|
||||
@@ -20,10 +21,25 @@ export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, a
|
||||
|
||||
// One replay semantics: re-enter the guarded route carrying the approval
|
||||
// row as the grant. The policy hold is satisfied, but the structural
|
||||
// checks run live — un-wiring the pair between hold and approve now
|
||||
// blocks delivery (the throw surfaces via the response handler's
|
||||
// "approved, but applying it failed" notify).
|
||||
await routeAgentMessage(msg, session, { grant: approval });
|
||||
// checks run live — a deny here (destination revoked while the card was
|
||||
// pending, dead or mismatched grant) is an EXPECTED policy outcome, not a
|
||||
// crash: tell the requester, log a warning, and let anything else keep the
|
||||
// response handler's failure path.
|
||||
try {
|
||||
await routeAgentMessage(msg, session, { grant: approval });
|
||||
} catch (err) {
|
||||
if (err instanceof GuardDenyError) {
|
||||
log.warn('Approved a2a replay refused by the guard', {
|
||||
from: session.agent_group_id,
|
||||
to: platform_id,
|
||||
msgId: msg.id,
|
||||
reason: err.message,
|
||||
});
|
||||
notify(`Message approved, but not delivered — no longer authorized: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
log.info('Held agent message delivered after approval', {
|
||||
from: session.agent_group_id,
|
||||
to: platform_id,
|
||||
|
||||
Reference in New Issue
Block a user