mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): render permission-request sentinel card in thread timeline
Implements the desktop side of issue #4938: a kind-9 sentinel published by buzz-acp is parsed and displayed as an interactive card in the thread view. Parser (permissionRequest.ts): - Discriminated union PermissionRequestPending | PermissionRequestResolved aligned to the frozen schema (event b31c716e): requestNonce, sessionId, turnId, optionIds[], labels{}, hasDurableRule, durableRuleNote, outcome, chosenOptionId, originalEventId. - All untrusted display strings capped at 200 chars; optionIds bounded to 10. - extractPermissionRequest() and stripPermissionRequestSentinel() are render-safe (never throw). Compute helper (computePermissionRequest.ts): - D1 signer gate: kind-9 must be signed by the channel's known agent pubkey. - Edit authenticity: resolved state accepted only from kind-40003 signed by the original agent (not owner, not attacker). - selectProseOrPermission() mirrors selectProseOrNudge() from configNudge. Card (permission-request-card.tsx): - Pending: renders option buttons from optionIds + labels[optionId]. Deny-style button heuristic uses label text (opaque optionIds carry no semantic). ExpiryCountdown ticks down to expiresAt; expired cards show 'Timed out'. D5 durable-rule disclosure (durableRuleNote) shown below buttons when hasDurableRule is true. - Resolved: shows outcome label derived from outcome + chosenOptionId. - Actionable buttons gated: isOwner && state === 'pending' only. - sendPermissionDecision() is fire-and-forget; button disables on click with 'Decision sent'; re-enables on relay error so the user can retry. Integration (MessageRow.tsx, formatTimelineMessages.ts, types.ts): - editSignerPubkey threaded from formatTimelineMessages through TimelineMessage so the edit authenticity gate has the raw event signer without re-querying. - PermissionRequestCardBlock wraps useIdentityQuery to resolve the current viewer, computes isOwner, and renders the card when a trusted sentinel is present. React.memo with custom comparator keeps MessageRow re-render budget clean. Auth helper (permissionRequestAuthPubkey.ts): - getPermissionRequestAgentPubkey() returns the agent pubkey only for known-agent-signed kind-9 events, mirroring getConfigNudgeAuthorPubkey. Tests: 33 named tests covering all 6 frozen fixtures verbatim plus rejection cases (bad version, unknown state, size bounds, malformed JSON, field invariants) and the full signer/edit-authenticity gate matrix. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -262,7 +262,12 @@ export function formatTimelineMessages(
|
||||
// the original (`h`, `p` mentions, etc.) stay untouched.
|
||||
const editsByTargetId = new Map<
|
||||
string,
|
||||
{ content: string; tags: string[][]; createdAt: number }
|
||||
{
|
||||
content: string;
|
||||
tags: string[][];
|
||||
createdAt: number;
|
||||
signerPubkey: string;
|
||||
}
|
||||
>();
|
||||
for (const event of events) {
|
||||
if (
|
||||
@@ -293,6 +298,7 @@ export function formatTimelineMessages(
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
createdAt: event.created_at,
|
||||
signerPubkey: normalizePubkey(event.pubkey),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -504,6 +510,7 @@ export function formatTimelineMessages(
|
||||
: undefined,
|
||||
time: formatTime(event.created_at),
|
||||
body: edit ? edit.content : event.content,
|
||||
editSignerPubkey: edit?.signerPubkey,
|
||||
parentId: thread.parentId,
|
||||
rootId: thread.rootId,
|
||||
depth: getDepth(event),
|
||||
|
||||
@@ -24,6 +24,13 @@ export type TimelineMessage = {
|
||||
* user that cryptographically signed the event.
|
||||
*/
|
||||
signerPubkey?: string;
|
||||
/**
|
||||
* Signer pubkey of the most recent authorized kind-40003 edit, normalized to
|
||||
* lowercase hex. Present only when an edit exists. Used by the
|
||||
* `PermissionRequestCard` to enforce edit authenticity: only edits signed by
|
||||
* the original agent may resolve the card.
|
||||
*/
|
||||
editSignerPubkey?: string;
|
||||
author: string;
|
||||
/** True when the displayed author is known to be an agent. */
|
||||
isAgent?: boolean;
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
|
||||
import { getPermissionRequestAgentPubkey } from "@/features/messages/ui/permissionRequestAuthPubkey";
|
||||
import { PermissionRequestCardBlock } from "@/features/messages/ui/PermissionRequestCardBlock";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
@@ -647,6 +649,20 @@ export const MessageRow = React.memo(
|
||||
const messageBodyNode = (
|
||||
<>
|
||||
{renderBody()}
|
||||
{channelId && message.isAgent ? (
|
||||
<PermissionRequestCardBlock
|
||||
agentPubkey={getPermissionRequestAgentPubkey(
|
||||
message,
|
||||
isKnownAgentPubkey,
|
||||
)}
|
||||
channelId={channelId}
|
||||
content={message.body}
|
||||
editSignerPubkey={message.editSignerPubkey}
|
||||
interactive
|
||||
ownerPubkey={message.ownerPubkey}
|
||||
signerPubkey={message.signerPubkey}
|
||||
/>
|
||||
) : null}
|
||||
{continuationMetadataNode}
|
||||
<MessageReactions
|
||||
messageId={message.id}
|
||||
@@ -909,6 +925,7 @@ export const MessageRow = React.memo(
|
||||
prev.message.kind === next.message.kind &&
|
||||
prev.message.pending === next.message.pending &&
|
||||
prev.message.edited === next.message.edited &&
|
||||
prev.message.editSignerPubkey === next.message.editSignerPubkey &&
|
||||
// Value comparisons, not identity: these arrays are rebuilt with fresh
|
||||
// identities on every ingest/refetch even when unchanged — identity
|
||||
// checks made every row re-render on every streamed event in an open
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Wrapper that handles the current-viewer identity check for the
|
||||
* `PermissionRequestCard`, keeping React hooks out of the memo-heavy
|
||||
* `MessageRow` component.
|
||||
*
|
||||
* Renders nothing when `computePermissionRequest` returns null (no trusted
|
||||
* sentinel, wrong signer, or non-interactive surface).
|
||||
*/
|
||||
import * as React from "react";
|
||||
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { computePermissionRequest } from "@/shared/lib/computePermissionRequest";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { AttachmentGroup } from "@/shared/ui/attachment";
|
||||
import { PermissionRequestCard } from "@/shared/ui/permission-request-card";
|
||||
|
||||
export type PermissionRequestCardBlockProps = {
|
||||
/** Message body content — may contain the sentinel or may not. */
|
||||
content: string;
|
||||
/** Whether this is an interactive render surface. Non-interactive → no card. */
|
||||
interactive: boolean;
|
||||
/**
|
||||
* Normalized hex pubkey of the known agent that signed the kind-9 event.
|
||||
* Undefined → card disabled (no agent signer on this message).
|
||||
*/
|
||||
agentPubkey: string | undefined;
|
||||
/** Raw signer pubkey from the signed event envelope. */
|
||||
signerPubkey: string | undefined;
|
||||
/**
|
||||
* Signer pubkey of the most recent authorized kind-40003 edit, if any.
|
||||
* Used to enforce edit authenticity: only agent-signed edits resolve the card.
|
||||
*/
|
||||
editSignerPubkey?: string;
|
||||
/** Verified owner pubkey for the agent (from the agent's profile). */
|
||||
ownerPubkey?: string | null;
|
||||
/** Channel ID for routing the permission decision click. */
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
export const PermissionRequestCardBlock = React.memo(
|
||||
function PermissionRequestCardBlock({
|
||||
content,
|
||||
interactive,
|
||||
agentPubkey,
|
||||
signerPubkey,
|
||||
editSignerPubkey,
|
||||
ownerPubkey,
|
||||
channelId,
|
||||
}: PermissionRequestCardBlockProps) {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const viewerPubkey = identityQuery.data?.pubkey;
|
||||
|
||||
const request = computePermissionRequest(
|
||||
content,
|
||||
interactive,
|
||||
agentPubkey,
|
||||
signerPubkey,
|
||||
editSignerPubkey,
|
||||
);
|
||||
|
||||
if (request === null || !agentPubkey) return null;
|
||||
|
||||
const isOwner =
|
||||
!!viewerPubkey &&
|
||||
!!ownerPubkey &&
|
||||
normalizePubkey(viewerPubkey) === normalizePubkey(ownerPubkey);
|
||||
|
||||
return (
|
||||
<AttachmentGroup
|
||||
className="max-w-full flex-wrap overflow-visible pb-0"
|
||||
data-permission-request=""
|
||||
>
|
||||
<PermissionRequestCard
|
||||
agentPubkey={agentPubkey}
|
||||
channelId={channelId}
|
||||
isOwner={isOwner}
|
||||
request={request}
|
||||
/>
|
||||
</AttachmentGroup>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.content === next.content &&
|
||||
prev.interactive === next.interactive &&
|
||||
prev.agentPubkey === next.agentPubkey &&
|
||||
prev.signerPubkey === next.signerPubkey &&
|
||||
prev.editSignerPubkey === next.editSignerPubkey &&
|
||||
prev.ownerPubkey === next.ownerPubkey &&
|
||||
prev.channelId === next.channelId,
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
/**
|
||||
* Returns the agent pubkey to use for the `PermissionRequestCard` for a given
|
||||
* message, or `undefined` when the permission-card path should be disabled.
|
||||
*
|
||||
* The card is enabled ONLY when:
|
||||
* 1. `message.kind === KIND_STREAM_MESSAGE` — restricts to the setup-listener
|
||||
* wire format (kind:9).
|
||||
* 2. `message.signerPubkey` is set and passes `isKnownAgentPubkey` —
|
||||
* authenticates against the raw event signer (NOT `message.pubkey`,
|
||||
* which may be a relay-delegated display author).
|
||||
*
|
||||
* Mirrors `getConfigNudgeAuthorPubkey` — same signer-vs-delegated-author
|
||||
* distinction, same test-friendly pure-function shape.
|
||||
*/
|
||||
export function getPermissionRequestAgentPubkey(
|
||||
message: Pick<TimelineMessage, "kind" | "signerPubkey">,
|
||||
isKnownAgentPubkey: (pubkey: string) => boolean,
|
||||
): string | undefined {
|
||||
if (
|
||||
message.kind === KIND_STREAM_MESSAGE &&
|
||||
message.signerPubkey &&
|
||||
isKnownAgentPubkey(message.signerPubkey)
|
||||
) {
|
||||
return message.signerPubkey;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Named test matrix for `computePermissionRequest` and `selectProseOrPermission`.
|
||||
*
|
||||
* Fixtures use the frozen schema (event b31c716e).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
computePermissionRequest,
|
||||
selectProseOrPermission,
|
||||
} from "./computePermissionRequest.ts";
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const AGENT_PUBKEY =
|
||||
"aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899";
|
||||
const ATTACKER_PUBKEY =
|
||||
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
const OWNER_PUBKEY =
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
|
||||
|
||||
const PENDING_PAYLOAD = {
|
||||
v: 1,
|
||||
state: "pending",
|
||||
requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 9999999999,
|
||||
optionIds: ["opt-allow", "opt-deny"],
|
||||
labels: { "opt-allow": "Allow once", "opt-deny": "Deny" },
|
||||
hasDurableRule: false,
|
||||
durableRuleNote: null,
|
||||
};
|
||||
|
||||
const RESOLVED_PAYLOAD = {
|
||||
v: 1,
|
||||
state: "resolved",
|
||||
requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4",
|
||||
originalEventId:
|
||||
"deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 9999999999,
|
||||
optionIds: ["opt-allow", "opt-deny"],
|
||||
labels: { "opt-allow": "Allow once", "opt-deny": "Deny" },
|
||||
hasDurableRule: false,
|
||||
durableRuleNote: null,
|
||||
outcome: "applied",
|
||||
chosenOptionId: "opt-allow",
|
||||
};
|
||||
|
||||
function fence(payload) {
|
||||
return `\`\`\`buzz:permission-request\n${JSON.stringify(payload)}\n\`\`\``;
|
||||
}
|
||||
|
||||
function body(payload) {
|
||||
return `May I?\n\n${fence(payload)}`;
|
||||
}
|
||||
|
||||
// ── computePermissionRequest ──────────────────────────────────────────────────
|
||||
|
||||
test("test_not_interactive_returns_null", () => {
|
||||
assert.equal(
|
||||
computePermissionRequest(
|
||||
body(PENDING_PAYLOAD),
|
||||
false,
|
||||
AGENT_PUBKEY,
|
||||
AGENT_PUBKEY,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_missing_agentPubkey_returns_null", () => {
|
||||
assert.equal(
|
||||
computePermissionRequest(
|
||||
body(PENDING_PAYLOAD),
|
||||
true,
|
||||
undefined,
|
||||
AGENT_PUBKEY,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_missing_signerPubkey_returns_null", () => {
|
||||
assert.equal(
|
||||
computePermissionRequest(
|
||||
body(PENDING_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
undefined,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_forged_card_wrong_signer_returns_null", () => {
|
||||
// agentPubkey (channel's known agent) ≠ signerPubkey (event signer)
|
||||
assert.equal(
|
||||
computePermissionRequest(
|
||||
body(PENDING_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
ATTACKER_PUBKEY,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_valid_signer_returns_payload", () => {
|
||||
const result = computePermissionRequest(
|
||||
body(PENDING_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
AGENT_PUBKEY,
|
||||
);
|
||||
assert.deepEqual(result, PENDING_PAYLOAD);
|
||||
});
|
||||
|
||||
test("test_signer_check_is_case_insensitive", () => {
|
||||
const result = computePermissionRequest(
|
||||
body(PENDING_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY.toUpperCase(),
|
||||
AGENT_PUBKEY.toLowerCase(),
|
||||
);
|
||||
assert.deepEqual(result, PENDING_PAYLOAD);
|
||||
});
|
||||
|
||||
test("test_no_sentinel_returns_null", () => {
|
||||
assert.equal(
|
||||
computePermissionRequest(
|
||||
"No sentinel here",
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
AGENT_PUBKEY,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_agent_signed_edit_resolves_card", () => {
|
||||
const result = computePermissionRequest(
|
||||
body(RESOLVED_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
AGENT_PUBKEY, // original event signer
|
||||
AGENT_PUBKEY, // edit signer == agent ✓
|
||||
);
|
||||
assert.deepEqual(result, RESOLVED_PAYLOAD);
|
||||
});
|
||||
|
||||
test("test_owner_signed_edit_does_not_resolve", () => {
|
||||
assert.equal(
|
||||
computePermissionRequest(
|
||||
body(RESOLVED_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
AGENT_PUBKEY,
|
||||
OWNER_PUBKEY, // edit signer is owner, not agent ✗
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_attacker_signed_edit_does_not_resolve", () => {
|
||||
assert.equal(
|
||||
computePermissionRequest(
|
||||
body(RESOLVED_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
AGENT_PUBKEY,
|
||||
ATTACKER_PUBKEY, // attacker edit ✗
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_resolved_body_with_no_edit_arrived_parses_body_directly", () => {
|
||||
// When editSignerPubkey is undefined, no edit-authenticity check runs.
|
||||
// If the original event body happened to contain a resolved sentinel, we
|
||||
// return it. This handles the edge case where the edit arrives before we
|
||||
// query the original event.
|
||||
const result = computePermissionRequest(
|
||||
body(RESOLVED_PAYLOAD),
|
||||
true,
|
||||
AGENT_PUBKEY,
|
||||
AGENT_PUBKEY,
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(result, RESOLVED_PAYLOAD);
|
||||
});
|
||||
|
||||
// ── selectProseOrPermission ───────────────────────────────────────────────────
|
||||
|
||||
test("test_selectProseOrPermission_returns_markdown_when_no_request", () => {
|
||||
const node = "markdown-node";
|
||||
assert.equal(selectProseOrPermission(null, node), node);
|
||||
});
|
||||
|
||||
test("test_selectProseOrPermission_returns_null_when_request_present", () => {
|
||||
// Pass a typed object directly (not parsed from content)
|
||||
assert.equal(selectProseOrPermission(PENDING_PAYLOAD, "markdown-node"), null);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { PermissionRequestPayload } from "@/shared/lib/permissionRequest";
|
||||
import { extractPermissionRequest } from "@/shared/lib/permissionRequest";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/**
|
||||
* Pure helper that computes the active `PermissionRequestPayload` for a
|
||||
* message body.
|
||||
*
|
||||
* The card is active ONLY when:
|
||||
* 1. `interactive` is true — non-interactive surfaces (search snippets, etc.)
|
||||
* never render actionable cards.
|
||||
* 2. `agentPubkey` is provided and matches `signerPubkey` — authenticates
|
||||
* the sentinel against the raw event signer from the signed envelope, not
|
||||
* a relay-delegated author. This enforces the D1 requirement that forged
|
||||
* cards (wrong signer) never become actionable.
|
||||
* 3. For resolved state: `editSignerPubkey` must equal `agentPubkey` — only
|
||||
* edits signed by the original agent may flip the card to resolved.
|
||||
* Owner-signed or attacker-signed edits are rejected.
|
||||
*
|
||||
* Extracted into its own module so it can be tested without pulling in
|
||||
* markdown.tsx's heavy dependency chain.
|
||||
*/
|
||||
export function computePermissionRequest(
|
||||
content: string,
|
||||
interactive: boolean,
|
||||
/** Normalized hex pubkey of the known agent for this channel (from signed envelope). */
|
||||
agentPubkey: string | undefined | null,
|
||||
/** Raw signer pubkey of the message event (from the signed envelope's pubkey field). */
|
||||
signerPubkey: string | undefined | null,
|
||||
/**
|
||||
* Signer pubkey of the most recent kind-40003 edit for this message, if any.
|
||||
* Undefined/null means no edit has arrived. Only edits where
|
||||
* `editSignerPubkey === agentPubkey` may resolve the card.
|
||||
*/
|
||||
editSignerPubkey?: string | null,
|
||||
): PermissionRequestPayload | null {
|
||||
if (!interactive || !agentPubkey || !signerPubkey) return null;
|
||||
|
||||
// D1 signer gate: the kind-9 must be signed by the known agent.
|
||||
if (normalizePubkey(signerPubkey) !== normalizePubkey(agentPubkey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = extractPermissionRequest(content);
|
||||
if (payload === null) return null;
|
||||
|
||||
// For resolved state (edit has arrived): verify the edit was signed by the
|
||||
// original agent. Owner-signed or attacker-signed edits are rejected.
|
||||
if (
|
||||
payload.state === "resolved" &&
|
||||
editSignerPubkey !== undefined &&
|
||||
editSignerPubkey !== null
|
||||
) {
|
||||
if (normalizePubkey(editSignerPubkey) !== normalizePubkey(agentPubkey)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `markdownNode` when no trusted permission-request payload is present,
|
||||
* or `null` when the card should suppress the prose.
|
||||
*
|
||||
* Mirrors `selectProseOrNudge` from computeConfigNudge.ts — same prose-
|
||||
* suppression contract.
|
||||
*/
|
||||
export function selectProseOrPermission(
|
||||
request: PermissionRequestPayload | null,
|
||||
markdownNode: ReactNode,
|
||||
): ReactNode {
|
||||
return request === null ? markdownNode : null;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Named test matrix for the `permissionRequest` sentinel parser.
|
||||
*
|
||||
* All fixtures are verbatim from Duncan's frozen schema (event b31c716e).
|
||||
* Tests cover: parse, reject, and sentinel extraction/stripping.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
// ── Import via dynamic import to work with the ESM test runner ────────────────
|
||||
// The tests run against the compiled JS (tsc outputs CJS); for the mjs runner
|
||||
// we use a relative path that resolves after build or through tsx.
|
||||
|
||||
const mod = await import("./permissionRequest.js").catch(
|
||||
() => import("./permissionRequest.ts"),
|
||||
);
|
||||
const { extractPermissionRequest, stripPermissionRequestSentinel } = mod;
|
||||
|
||||
// ── Fixtures (verbatim from event b31c716e) ───────────────────────────────────
|
||||
|
||||
const PENDING_NORMAL = {
|
||||
v: 1,
|
||||
state: "pending",
|
||||
requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 1786206732,
|
||||
optionIds: ["opt-allow", "opt-deny"],
|
||||
labels: { "opt-allow": "Allow once", "opt-deny": "Deny" },
|
||||
hasDurableRule: false,
|
||||
durableRuleNote: null,
|
||||
};
|
||||
|
||||
const PENDING_DURABLE = {
|
||||
v: 1,
|
||||
state: "pending",
|
||||
requestNonce: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 1786206732,
|
||||
optionIds: ["opt-allow-once", "opt-allow-always", "opt-deny"],
|
||||
labels: {
|
||||
"opt-allow-once": "Allow once",
|
||||
"opt-allow-always": "Always allow",
|
||||
"opt-deny": "Deny",
|
||||
},
|
||||
hasDurableRule: true,
|
||||
durableRuleNote:
|
||||
"Includes an 'Always allow' option — creates a machine-wide durable rule in Codex.",
|
||||
};
|
||||
|
||||
const RESOLVED_APPLIED = {
|
||||
v: 1,
|
||||
state: "resolved",
|
||||
requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4",
|
||||
originalEventId:
|
||||
"deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 1786206732,
|
||||
optionIds: ["opt-allow", "opt-deny"],
|
||||
labels: { "opt-allow": "Allow once", "opt-deny": "Deny" },
|
||||
hasDurableRule: false,
|
||||
durableRuleNote: null,
|
||||
outcome: "applied",
|
||||
chosenOptionId: "opt-allow",
|
||||
};
|
||||
|
||||
const RESOLVED_TIMED_OUT = {
|
||||
v: 1,
|
||||
state: "resolved",
|
||||
requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4",
|
||||
originalEventId:
|
||||
"deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 1786206732,
|
||||
optionIds: ["opt-allow", "opt-deny"],
|
||||
labels: { "opt-allow": "Allow once", "opt-deny": "Deny" },
|
||||
hasDurableRule: false,
|
||||
durableRuleNote: null,
|
||||
outcome: "timed_out",
|
||||
chosenOptionId: null,
|
||||
};
|
||||
|
||||
const RESOLVED_CANCELLED = {
|
||||
v: 1,
|
||||
state: "resolved",
|
||||
requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4",
|
||||
originalEventId:
|
||||
"deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 1786206732,
|
||||
optionIds: ["opt-allow", "opt-deny"],
|
||||
labels: { "opt-allow": "Allow once", "opt-deny": "Deny" },
|
||||
hasDurableRule: false,
|
||||
durableRuleNote: null,
|
||||
outcome: "cancelled",
|
||||
chosenOptionId: null,
|
||||
};
|
||||
|
||||
const RESOLVED_REJECTED = {
|
||||
v: 1,
|
||||
state: "resolved",
|
||||
requestNonce: "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4",
|
||||
originalEventId:
|
||||
"deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead",
|
||||
sessionId: "sess-abc",
|
||||
turnId: "turn-xyz",
|
||||
expiresAt: 1786206732,
|
||||
optionIds: ["opt-allow", "opt-deny"],
|
||||
labels: { "opt-allow": "Allow once", "opt-deny": "Deny" },
|
||||
hasDurableRule: false,
|
||||
durableRuleNote: null,
|
||||
outcome: "rejected",
|
||||
chosenOptionId: null,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function wrap(payload) {
|
||||
return `Some prose above.\n\n\`\`\`buzz:permission-request\n${JSON.stringify(payload)}\n\`\`\`\n`;
|
||||
}
|
||||
|
||||
// ── Parse: happy-path fixtures ────────────────────────────────────────────────
|
||||
|
||||
describe("extractPermissionRequest — pending fixtures", () => {
|
||||
it("test_pending_normal_parses_correctly", () => {
|
||||
const result = extractPermissionRequest(wrap(PENDING_NORMAL));
|
||||
assert.ok(result !== null, "should parse");
|
||||
assert.equal(result.state, "pending");
|
||||
assert.equal(result.requestNonce, "a9f3b2c1-d4e5-4f6a-b7c8-d9e0f1a2b3c4");
|
||||
assert.equal(result.sessionId, "sess-abc");
|
||||
assert.equal(result.turnId, "turn-xyz");
|
||||
assert.equal(result.expiresAt, 1786206732);
|
||||
assert.deepEqual(result.optionIds, ["opt-allow", "opt-deny"]);
|
||||
assert.deepEqual(result.labels, {
|
||||
"opt-allow": "Allow once",
|
||||
"opt-deny": "Deny",
|
||||
});
|
||||
assert.equal(result.hasDurableRule, false);
|
||||
assert.equal(result.durableRuleNote, null);
|
||||
// pending has no originalEventId, outcome, chosenOptionId
|
||||
assert.ok(!("originalEventId" in result));
|
||||
assert.ok(!("outcome" in result));
|
||||
assert.ok(!("chosenOptionId" in result));
|
||||
});
|
||||
|
||||
it("test_pending_durable_rule_parses_correctly", () => {
|
||||
const result = extractPermissionRequest(wrap(PENDING_DURABLE));
|
||||
assert.ok(result !== null, "should parse");
|
||||
assert.equal(result.state, "pending");
|
||||
assert.equal(result.hasDurableRule, true);
|
||||
assert.equal(
|
||||
result.durableRuleNote,
|
||||
"Includes an 'Always allow' option — creates a machine-wide durable rule in Codex.",
|
||||
);
|
||||
assert.deepEqual(result.optionIds, [
|
||||
"opt-allow-once",
|
||||
"opt-allow-always",
|
||||
"opt-deny",
|
||||
]);
|
||||
assert.equal(result.labels["opt-allow-always"], "Always allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPermissionRequest — resolved fixtures", () => {
|
||||
it("test_resolved_applied_parses_correctly", () => {
|
||||
const result = extractPermissionRequest(wrap(RESOLVED_APPLIED));
|
||||
assert.ok(result !== null, "should parse");
|
||||
assert.equal(result.state, "resolved");
|
||||
assert.equal(result.outcome, "applied");
|
||||
assert.equal(result.chosenOptionId, "opt-allow");
|
||||
assert.equal(
|
||||
result.originalEventId,
|
||||
"deadbeef0001deadbeef0002deadbeef0003deadbeef0004deadbeef0005dead",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_resolved_timed_out_parses_correctly", () => {
|
||||
const result = extractPermissionRequest(wrap(RESOLVED_TIMED_OUT));
|
||||
assert.ok(result !== null, "should parse");
|
||||
assert.equal(result.state, "resolved");
|
||||
assert.equal(result.outcome, "timed_out");
|
||||
assert.equal(result.chosenOptionId, null);
|
||||
});
|
||||
|
||||
it("test_resolved_cancelled_parses_correctly", () => {
|
||||
const result = extractPermissionRequest(wrap(RESOLVED_CANCELLED));
|
||||
assert.ok(result !== null, "should parse");
|
||||
assert.equal(result.state, "resolved");
|
||||
assert.equal(result.outcome, "cancelled");
|
||||
assert.equal(result.chosenOptionId, null);
|
||||
});
|
||||
|
||||
it("test_resolved_rejected_parses_correctly", () => {
|
||||
const result = extractPermissionRequest(wrap(RESOLVED_REJECTED));
|
||||
assert.ok(result !== null, "should parse");
|
||||
assert.equal(result.state, "resolved");
|
||||
assert.equal(result.outcome, "rejected");
|
||||
assert.equal(result.chosenOptionId, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Parse: rejection cases ────────────────────────────────────────────────────
|
||||
|
||||
describe("extractPermissionRequest — rejection cases", () => {
|
||||
it("test_no_sentinel_returns_null", () => {
|
||||
assert.equal(extractPermissionRequest("just prose, no fence"), null);
|
||||
});
|
||||
|
||||
it("test_wrong_version_returns_null", () => {
|
||||
const bad = { ...PENDING_NORMAL, v: 2 };
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_unknown_state_returns_null", () => {
|
||||
const bad = { ...PENDING_NORMAL, state: "unknown" };
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_empty_optionIds_returns_null", () => {
|
||||
const bad = { ...PENDING_NORMAL, optionIds: [] };
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_too_many_optionIds_returns_null", () => {
|
||||
const bad = {
|
||||
...PENDING_NORMAL,
|
||||
optionIds: Array.from({ length: 11 }, (_, i) => `opt-${i}`),
|
||||
labels: Object.fromEntries(
|
||||
Array.from({ length: 11 }, (_, i) => [`opt-${i}`, `Option ${i}`]),
|
||||
),
|
||||
};
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_label_exceeding_200_chars_returns_null", () => {
|
||||
const longLabel = "x".repeat(201);
|
||||
const bad = {
|
||||
...PENDING_NORMAL,
|
||||
labels: { "opt-allow": longLabel, "opt-deny": "Deny" },
|
||||
};
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_missing_requestNonce_returns_null", () => {
|
||||
const { requestNonce: _, ...bad } = PENDING_NORMAL;
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_resolved_missing_originalEventId_returns_null", () => {
|
||||
const { originalEventId: _, ...bad } = RESOLVED_APPLIED;
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_resolved_originalEventId_wrong_length_returns_null", () => {
|
||||
const bad = { ...RESOLVED_APPLIED, originalEventId: "tooshort" };
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
|
||||
it("test_invalid_json_returns_null", () => {
|
||||
const content = "```buzz:permission-request\n{not valid json}\n```\n";
|
||||
assert.equal(extractPermissionRequest(content), null);
|
||||
});
|
||||
|
||||
it("test_empty_fence_body_returns_null", () => {
|
||||
const content = "```buzz:permission-request\n\n```\n";
|
||||
assert.equal(extractPermissionRequest(content), null);
|
||||
});
|
||||
|
||||
it("test_non_finite_expiresAt_returns_null", () => {
|
||||
const bad = { ...PENDING_NORMAL, expiresAt: Infinity };
|
||||
assert.equal(extractPermissionRequest(wrap(bad)), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── stripPermissionRequestSentinel ───────────────────────────────────────────
|
||||
|
||||
describe("stripPermissionRequestSentinel", () => {
|
||||
it("test_strip_removes_fence_and_preserves_prose", () => {
|
||||
const content = `Some prose.\n\n\`\`\`buzz:permission-request\n${JSON.stringify(PENDING_NORMAL)}\n\`\`\`\n`;
|
||||
const stripped = stripPermissionRequestSentinel(content);
|
||||
assert.ok(!stripped.includes("buzz:permission-request"));
|
||||
assert.ok(stripped.includes("Some prose."));
|
||||
});
|
||||
|
||||
it("test_strip_no_sentinel_returns_original", () => {
|
||||
const content = "just prose here";
|
||||
assert.equal(stripPermissionRequestSentinel(content), content);
|
||||
});
|
||||
|
||||
it("test_strip_empty_string_returns_empty", () => {
|
||||
assert.equal(stripPermissionRequestSentinel(""), "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Utilities for extracting and parsing the `buzz:permission-request` sentinel
|
||||
* that `buzz-acp` publishes as a kind:9 reply into the triggering thread when
|
||||
* an `ask`-policy permission request is admitted.
|
||||
*
|
||||
* Wire format (versioned discriminated union, schema v1 — frozen at event
|
||||
* b31c716e):
|
||||
*
|
||||
* ```buzz:permission-request
|
||||
* {"v":1,"state":"pending", … }
|
||||
* ```
|
||||
*
|
||||
* The prose above the fence is the plaintext fallback for non-card clients.
|
||||
* Desktop strips the sentinel and renders a `PermissionRequestCard` instead.
|
||||
*
|
||||
* Security invariants:
|
||||
* - `agentPubkey` and `channelId` are derived from the SIGNED EVENT ENVELOPE,
|
||||
* never from sentinel JSON.
|
||||
* - `optionId` values are opaque — treated as arbitrary strings; never
|
||||
* interpreted as ACP kinds by the renderer.
|
||||
* - Labels come from `labels[optionId]` — harness-provided display strings,
|
||||
* not raw ACP kind names.
|
||||
* - All untrusted display strings are size-bounded (≤ 200 chars) and
|
||||
* HTML-escaped by React at render time.
|
||||
*/
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pending sentinel — the card is actionable.
|
||||
*
|
||||
* `requestNonce` and `expiresAt` are trusted as unsigned ints from the harness.
|
||||
* `labels` values are untrusted display strings (capped at 200 chars).
|
||||
*/
|
||||
export type PermissionRequestPending = {
|
||||
v: 1;
|
||||
state: "pending";
|
||||
requestNonce: string;
|
||||
sessionId: string | null;
|
||||
turnId: string | null;
|
||||
expiresAt: number;
|
||||
/** Opaque option IDs. Size-bounded: ≤ 10. */
|
||||
optionIds: string[];
|
||||
/** Harness-provided display labels keyed by optionId. Each ≤ 200 chars. */
|
||||
labels: Record<string, string>;
|
||||
/** True when an `allow_always` option is present (D5 durable-rule disclosure). */
|
||||
hasDurableRule: boolean;
|
||||
/**
|
||||
* Human-readable durable-rule disclosure note. Non-null only when
|
||||
* `hasDurableRule === true`. E.g. "Includes an 'Always allow' option —
|
||||
* creates a machine-wide durable rule in Codex."
|
||||
*/
|
||||
durableRuleNote: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved sentinel — the card is non-actionable (archived state).
|
||||
*
|
||||
* Published by the harness as a kind-40003 edit signed by the original agent.
|
||||
* `originalEventId` is the kind-9 event ID — correlates the edit to the card.
|
||||
*/
|
||||
export type PermissionRequestResolved = {
|
||||
v: 1;
|
||||
state: "resolved";
|
||||
requestNonce: string;
|
||||
originalEventId: string;
|
||||
sessionId: string | null;
|
||||
turnId: string | null;
|
||||
expiresAt: number;
|
||||
optionIds: string[];
|
||||
labels: Record<string, string>;
|
||||
hasDurableRule: boolean;
|
||||
durableRuleNote: string | null;
|
||||
/** One of "applied" | "timed_out" | "cancelled" | "rejected". */
|
||||
outcome: string;
|
||||
/** Non-null only when outcome === "applied". */
|
||||
chosenOptionId: string | null;
|
||||
};
|
||||
|
||||
export type PermissionRequestPayload =
|
||||
| PermissionRequestPending
|
||||
| PermissionRequestResolved;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const FENCE_OPEN = "```buzz:permission-request";
|
||||
const FENCE_CLOSE = "```";
|
||||
|
||||
/** Maximum character length for any untrusted display string in the sentinel. */
|
||||
const MAX_LABEL_CHARS = 200;
|
||||
|
||||
/** Maximum number of option IDs in a sentinel (PERMISSION_OPTIONS_MAX). */
|
||||
const MAX_OPTION_IDS = 10;
|
||||
|
||||
// ── Extractor ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the `PermissionRequestPayload` from a message body, if present.
|
||||
*
|
||||
* Returns `null` when:
|
||||
* - the sentinel fence is absent
|
||||
* - the JSON inside is malformed
|
||||
* - the parsed value does not match the expected shape
|
||||
*
|
||||
* Never throws — all errors are swallowed so this is safe in the render path.
|
||||
*/
|
||||
export function extractPermissionRequest(
|
||||
content: string,
|
||||
): PermissionRequestPayload | null {
|
||||
const openIdx = content.indexOf(FENCE_OPEN);
|
||||
if (openIdx === -1) return null;
|
||||
|
||||
const jsonStart = content.indexOf("\n", openIdx);
|
||||
if (jsonStart === -1) return null;
|
||||
|
||||
const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart);
|
||||
if (closeIdx === -1) return null;
|
||||
|
||||
const json = content.slice(jsonStart + 1, closeIdx).trim();
|
||||
if (!json) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
return isPermissionRequestPayload(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `buzz:permission-request` sentinel block (and any preceding blank
|
||||
* line) from a message body. Returns the original string unchanged when no
|
||||
* sentinel is present.
|
||||
*
|
||||
* Used so the prose fallback renders without the raw code block.
|
||||
*/
|
||||
export function stripPermissionRequestSentinel(content: string): string {
|
||||
const openIdx = content.indexOf(FENCE_OPEN);
|
||||
if (openIdx === -1) return content;
|
||||
|
||||
const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, openIdx);
|
||||
if (closeIdx === -1) return content;
|
||||
|
||||
const afterFence = closeIdx + `\n${FENCE_CLOSE}`.length;
|
||||
const prose = content.slice(0, openIdx).replace(/\n{2,}$/, "\n");
|
||||
return prose + content.slice(afterFence);
|
||||
}
|
||||
|
||||
// ── Type guards ────────────────────────────────────────────────────────────────
|
||||
|
||||
function isSafeString(v: unknown): v is string {
|
||||
return typeof v === "string" && v.length <= MAX_LABEL_CHARS;
|
||||
}
|
||||
|
||||
function isNullableString(v: unknown): v is string | null {
|
||||
return v === null || isSafeString(v);
|
||||
}
|
||||
|
||||
function isLabelsRecord(v: unknown): v is Record<string, string> {
|
||||
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
||||
return Object.values(v as Record<string, unknown>).every(isSafeString);
|
||||
}
|
||||
|
||||
function isPermissionRequestPayload(v: unknown): v is PermissionRequestPayload {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const p = v as Record<string, unknown>;
|
||||
if (p.v !== 1) return false;
|
||||
|
||||
// Shared fields present in both states
|
||||
if (typeof p.requestNonce !== "string" || p.requestNonce.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isNullableString(p.sessionId)) return false;
|
||||
if (!isNullableString(p.turnId)) return false;
|
||||
if (typeof p.expiresAt !== "number" || !Number.isFinite(p.expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(p.optionIds) ||
|
||||
p.optionIds.length === 0 ||
|
||||
p.optionIds.length > MAX_OPTION_IDS ||
|
||||
!p.optionIds.every((id) => typeof id === "string" && id.length > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!isLabelsRecord(p.labels)) return false;
|
||||
if (typeof p.hasDurableRule !== "boolean") return false;
|
||||
if (!isNullableString(p.durableRuleNote)) return false;
|
||||
|
||||
if (p.state === "pending") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p.state === "resolved") {
|
||||
// originalEventId: 64-char hex string
|
||||
if (
|
||||
typeof p.originalEventId !== "string" ||
|
||||
p.originalEventId.length !== 64
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (typeof p.outcome !== "string" || p.outcome.length === 0) return false;
|
||||
// chosenOptionId: string or null (non-null only on "applied")
|
||||
if (p.chosenOptionId !== null && typeof p.chosenOptionId !== "string") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Inline card rendered when the desktop detects a `buzz:permission-request`
|
||||
* sentinel in a kind:9 message body. Mirrors the `ConfigNudgeCard` pattern.
|
||||
*
|
||||
* Security invariants enforced by the caller (`MessageRow`):
|
||||
* - `request` is only non-null when the kind-9 signer equals the known agent
|
||||
* pubkey for this channel (D1 signer gate in `computePermissionRequest`).
|
||||
* - Resolved state (`state === "resolved"`) requires the edit to have been
|
||||
* signed by the original agent (edit authenticity gate).
|
||||
*
|
||||
* Actionable buttons render ONLY when:
|
||||
* (a) `request.state === "pending"` AND
|
||||
* (b) `isOwner` is true (the current viewer is the verified agent owner).
|
||||
* All other viewers see a read-only card.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { sendPermissionDecision } from "@/shared/api/agentControl";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/shared/ui/attachment";
|
||||
import type {
|
||||
PermissionRequestPayload,
|
||||
PermissionRequestPending,
|
||||
} from "@/shared/lib/permissionRequest";
|
||||
|
||||
export type PermissionRequestCardProps = {
|
||||
className?: string;
|
||||
request: PermissionRequestPayload;
|
||||
/** Hex pubkey of the agent that published the sentinel. */
|
||||
agentPubkey: string;
|
||||
/** Channel ID for routing the permission decision. */
|
||||
channelId: string;
|
||||
/**
|
||||
* True when the current viewer is the verified agent owner.
|
||||
* Absent or false → read-only card (buttons suppressed).
|
||||
*/
|
||||
isOwner?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Heuristic: treat an option as "deny" when its harness label contains deny,
|
||||
* reject, or block (case-insensitive). Opaque optionIds carry no inherent
|
||||
* semantics — the label is the only display hint available.
|
||||
*/
|
||||
function isDenyLabel(label: string): boolean {
|
||||
const lower = label.toLowerCase();
|
||||
return (
|
||||
lower.includes("deny") ||
|
||||
lower.includes("reject") ||
|
||||
lower.includes("block")
|
||||
);
|
||||
}
|
||||
|
||||
function buttonClass(deny: boolean): string {
|
||||
return deny
|
||||
? "rounded px-2 py-0.5 text-xs font-medium border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
: "rounded px-2 py-0.5 text-xs font-medium border border-green-600/40 text-green-700 dark:text-green-400 hover:bg-green-600/10 disabled:opacity-50";
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome display label — maps the harness outcome string to human copy.
|
||||
*/
|
||||
function outcomeLabel(
|
||||
outcome: string,
|
||||
chosenOptionId: string | null,
|
||||
labels: Record<string, string>,
|
||||
): string {
|
||||
if (outcome === "applied" && chosenOptionId !== null) {
|
||||
const chosen = labels[chosenOptionId];
|
||||
return chosen ? `Approved: ${chosen}` : "Approved";
|
||||
}
|
||||
if (outcome === "timed_out") return "Timed out";
|
||||
if (outcome === "cancelled") return "Cancelled";
|
||||
if (outcome === "rejected") return "Denied";
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow/Deny buttons for a pending, owner-visible permission card.
|
||||
* On click: disables locally and shows "Decision sent". Convergence to final
|
||||
* state comes from the agent's kind-40003 edit or expiry — no promise of
|
||||
* immediate resolution from the harness response.
|
||||
*/
|
||||
function PermissionButtons({
|
||||
agentPubkey,
|
||||
channelId,
|
||||
request,
|
||||
}: {
|
||||
agentPubkey: string;
|
||||
channelId: string;
|
||||
request: PermissionRequestPending;
|
||||
}) {
|
||||
const [submitted, setSubmitted] = React.useState<string | null>(null);
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
const expired = request.expiresAt <= now;
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">Timed out</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted !== null) {
|
||||
return (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">Decision sent</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{request.optionIds.map((optionId) => {
|
||||
const label = request.labels[optionId] ?? optionId;
|
||||
return (
|
||||
<button
|
||||
key={optionId}
|
||||
type="button"
|
||||
className={buttonClass(isDenyLabel(label))}
|
||||
data-testid={`permission-decision-${optionId}`}
|
||||
onClick={() => {
|
||||
setSubmitted(optionId);
|
||||
void sendPermissionDecision(
|
||||
agentPubkey,
|
||||
channelId,
|
||||
request.requestNonce,
|
||||
optionId,
|
||||
).catch(() => {
|
||||
// Relay rejected the send. Re-enable so the user can retry.
|
||||
setSubmitted(null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{request.hasDurableRule && request.durableRuleNote !== null ? (
|
||||
<p className="max-w-[28rem] text-[11px] leading-4 text-amber-700 dark:text-amber-400 opacity-80">
|
||||
⚠ {request.durableRuleNote}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Countdown display for a pending card. Updates every second until expiry.
|
||||
* Returns null when already expired (buttons handle that state).
|
||||
*/
|
||||
function ExpiryCountdown({ expiresAt }: { expiresAt: number }) {
|
||||
const [secsLeft, setSecsLeft] = React.useState(() =>
|
||||
Math.max(0, Math.round(expiresAt - Date.now() / 1000)),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (secsLeft <= 0) return;
|
||||
const id = setInterval(() => {
|
||||
const remaining = Math.max(0, Math.round(expiresAt - Date.now() / 1000));
|
||||
setSecsLeft(remaining);
|
||||
if (remaining <= 0) clearInterval(id);
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [expiresAt, secsLeft]);
|
||||
|
||||
if (secsLeft <= 0) return null;
|
||||
const mins = Math.floor(secsLeft / 60);
|
||||
const secs = secsLeft % 60;
|
||||
const label = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||
return (
|
||||
<span className="text-[11px] text-muted-foreground opacity-70">
|
||||
{" "}
|
||||
· expires in {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PermissionRequestCard({
|
||||
className,
|
||||
request,
|
||||
agentPubkey,
|
||||
channelId,
|
||||
isOwner,
|
||||
}: PermissionRequestCardProps) {
|
||||
if (request.state === "resolved") {
|
||||
const resolvedLabel = outcomeLabel(
|
||||
request.outcome,
|
||||
request.chosenOptionId,
|
||||
request.labels,
|
||||
);
|
||||
return (
|
||||
<Attachment
|
||||
className={cn(
|
||||
"max-w-[min(100%,32rem)] shrink-0 shadow-none",
|
||||
className,
|
||||
)}
|
||||
orientation="horizontal"
|
||||
state="done"
|
||||
>
|
||||
<AttachmentMedia className="text-muted-foreground">
|
||||
<ShieldCheck aria-hidden="true" className="h-4 w-4" />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle className="whitespace-normal text-muted-foreground line-clamp-2">
|
||||
Permission request resolved
|
||||
</AttachmentTitle>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{resolvedLabel}
|
||||
</div>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending state
|
||||
const pending = request as PermissionRequestPending;
|
||||
const expired = pending.expiresAt <= Date.now() / 1000;
|
||||
|
||||
return (
|
||||
<Attachment
|
||||
className={cn("max-w-[min(100%,32rem)] shrink-0 shadow-none", className)}
|
||||
orientation="horizontal"
|
||||
state={expired ? "done" : "idle"}
|
||||
>
|
||||
<AttachmentMedia className="text-amber-600 dark:text-amber-400">
|
||||
<ShieldCheck aria-hidden="true" className="h-4 w-4" />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle className="whitespace-normal text-amber-700 dark:text-amber-400 line-clamp-2">
|
||||
Permission request
|
||||
{!expired ? <ExpiryCountdown expiresAt={pending.expiresAt} /> : null}
|
||||
</AttachmentTitle>
|
||||
{isOwner ? (
|
||||
<PermissionButtons
|
||||
agentPubkey={agentPubkey}
|
||||
channelId={channelId}
|
||||
request={pending}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Waiting for owner approval
|
||||
</div>
|
||||
)}
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user