From 89a5de9656e9fe20057063527ca8822565859747 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:24:48 +0900 Subject: [PATCH 1/2] fix(webapp): unwrap the {items} envelope for kb.list_* results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kb.list_* moved from a bare array to a `{items, _meta}` dict envelope (server deprecation, remove_in 1.4.0), but the webapp still typed and consumed these as flat arrays — so `r.data.map` in Shell and PendingView threw "map is not a function", crashing the whole tree through the error boundary on every page, the pending queue included. unwrap `items` once in rpc(), scoped to kb.list_* methods, tolerating the old bare-array shape and leaving kb.list_sessions ({sessions}) and non-list object results untouched. one central change fixes the pending page and every list view; the fan-out, optimistic caches, and views are unaffected. --- webapp/src/lib/rpc.test.ts | 24 ++++++++++++++++++++++++ webapp/src/lib/rpc.ts | 23 ++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/webapp/src/lib/rpc.test.ts b/webapp/src/lib/rpc.test.ts index 07c59112..9d96cfa8 100644 --- a/webapp/src/lib/rpc.test.ts +++ b/webapp/src/lib/rpc.test.ts @@ -29,6 +29,30 @@ test('rpc posts the envelope to /proxy/rpc with target + bearer headers and unwr expect(sent.id).toMatch(/^ui-\d+$/) }) +test('rpc unwraps the {items} envelope for kb.list_* results', async () => { + mockFetch(200, { id: 'x', ok: true, result: { items: [{ id: 'a' }, { id: 'b' }], _meta: { deprecation: {} } } }) + const out = await rpc<{ id: string }[]>(conn, 'kb.list_pending') + expect(out).toEqual([{ id: 'a' }, { id: 'b' }]) +}) + +test('rpc passes a bare-array kb.list_* result through unchanged (old server)', async () => { + mockFetch(200, { id: 'x', ok: true, result: [{ id: 'a' }] }) + const out = await rpc<{ id: string }[]>(conn, 'kb.list_claims') + expect(out).toEqual([{ id: 'a' }]) +}) + +test('rpc leaves kb.list_sessions {sessions} envelope untouched', async () => { + mockFetch(200, { id: 'x', ok: true, result: { sessions: [{ session_id: 's1' }] } }) + const out = await rpc<{ sessions: { session_id: string }[] }>(conn, 'kb.list_sessions') + expect(out).toEqual({ sessions: [{ session_id: 's1' }] }) +}) + +test('rpc does not unwrap items for non-list methods', async () => { + mockFetch(200, { id: 'x', ok: true, result: { items: [1, 2, 3] } }) + const out = await rpc<{ items: number[] }>(conn, 'kb.synthesize') + expect(out).toEqual({ items: [1, 2, 3] }) +}) + test('rpc omits authorization header when no token', async () => { const fn = mockFetch(200, { id: 'x', ok: true, result: {} }) await rpc({ endpoint: 'http://127.0.0.1:8731' }, 'kb.status') diff --git a/webapp/src/lib/rpc.ts b/webapp/src/lib/rpc.ts index c3ee7d00..fb73f731 100644 --- a/webapp/src/lib/rpc.ts +++ b/webapp/src/lib/rpc.ts @@ -44,7 +44,28 @@ export async function rpc( if (!body.ok || body.error) { throw new VouchRpcError(body.error?.code ?? 'unknown', body.error?.message ?? 'unknown error') } - return body.result as T + return unwrapListEnvelope(method, body.result) as T +} + +/** + * `kb.list_*` results moved from a bare array to a `{ items, _meta }` dict + * envelope (server deprecation, remove_in 1.4.0). Consumers still type these + * as flat arrays, so unwrap `items` here and keep tolerating the old shape — + * one place, so the fan-out, optimistic caches, and views are untouched. + * `kb.list_sessions` keeps its own `{ sessions }` key and has no `items`, so it + * passes through unchanged. + */ +function unwrapListEnvelope(method: string, result: T): T { + if ( + method.startsWith('kb.list_') && + result !== null && + typeof result === 'object' && + !Array.isArray(result) && + Array.isArray((result as { items?: unknown }).items) + ) { + return (result as unknown as { items: T }).items + } + return result } export async function fetchHealth(conn: VouchConnectionInfo): Promise { From 7420085d3c366552f1aab8555e7fb0b6eba580d7 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:54:52 +0900 Subject: [PATCH 2/2] feat(webapp): attribute console approvals to a reviewer identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a human approving in the review console was anonymized to the tokenless `unknown-agent` default, which collides with proposals filed under the same sentinel and trips the self-approval gate — so the owner literally could not approve their own KB's captured claims from the UI ("if not me, who?"). the console now carries a reviewer identity: rpc() sends it as X-Vouch-Agent (which the server already reads into the request actor), the dev proxy forwards that header, and a small topbar input lets the reviewer set their name, defaulting to `console`. approvals are attributed to a distinct human reviewer, pass the gate for every proposal, and the audit log records who approved — the review gate working correctly, not loosened. --- webapp/plugins/vouch-proxy.test.ts | 16 ++++++++++++++++ webapp/plugins/vouch-proxy.ts | 4 ++++ webapp/src/components/Shell.tsx | 13 +++++++++++++ webapp/src/lib/rpc.test.ts | 26 +++++++++++++++++++++++++- webapp/src/lib/rpc.ts | 28 +++++++++++++++++++++++++++- 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/webapp/plugins/vouch-proxy.test.ts b/webapp/plugins/vouch-proxy.test.ts index 3b1e0f0c..c58472c5 100644 --- a/webapp/plugins/vouch-proxy.test.ts +++ b/webapp/plugins/vouch-proxy.test.ts @@ -21,6 +21,7 @@ beforeAll(async () => { method: req.method, path: req.url, auth: req.headers.authorization ?? null, + agent: req.headers['x-vouch-agent'] ?? null, body, }), ) @@ -63,6 +64,21 @@ test('forwards POST body and Authorization to the target, rewriting /proxy prefi expect(JSON.parse(echoed.body).method).toBe('kb.status') }) +test('forwards the X-Vouch-Agent reviewer identity to the target', async () => { + const res = await fetch(`${proxyUrl}/proxy/rpc`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-vouch-target': upstreamUrl, + 'x-vouch-agent': 'alice', + }, + body: JSON.stringify({ id: '1', method: 'kb.approve', params: {} }), + }) + expect(res.status).toBe(200) + const echoed = await res.json() + expect(echoed.agent).toBe('alice') +}) + test('forwards GET /proxy/health to target /health', async () => { const res = await fetch(`${proxyUrl}/proxy/health`, { headers: { 'x-vouch-target': upstreamUrl }, diff --git a/webapp/plugins/vouch-proxy.ts b/webapp/plugins/vouch-proxy.ts index 8e2b9442..c3dfdee6 100644 --- a/webapp/plugins/vouch-proxy.ts +++ b/webapp/plugins/vouch-proxy.ts @@ -59,6 +59,10 @@ export function proxyMiddleware(): (req: IncomingMessage, res: ServerResponse, n const headers: Record = {} if (req.headers['content-type']) headers['content-type'] = String(req.headers['content-type']) if (req.headers.authorization) headers.authorization = String(req.headers.authorization) + // Reviewer identity: a human approving in the console must be attributed to + // themselves, not left as the tokenless `unknown-agent` default (which + // collides with the proposing agent and trips the self-approval gate). + if (req.headers['x-vouch-agent']) headers['x-vouch-agent'] = String(req.headers['x-vouch-agent']) const upstream = mod.request( { diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index f0d53b37..81a8341a 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -4,6 +4,7 @@ import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' import { ALL_SCOPE, useConnection } from '../connection/ConnectionContext' import { useFanout } from '../lib/fanout' +import { reviewerId, setReviewerId } from '../lib/rpc' import type { Proposal, SessionEntry } from '../lib/types' const THEME_KEY = 'vouch-ui.theme' @@ -33,6 +34,7 @@ export function Shell() { const location = useLocation() const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) ?? 'dark') const [manageOpen, setManageOpen] = useState(false) + const [reviewer, setReviewer] = useState(reviewerId) useEffect(() => { document.documentElement.dataset.theme = theme @@ -127,6 +129,17 @@ export function Shell() { ))} )} + { + setReviewer(e.target.value) + setReviewerId(e.target.value) + }} + placeholder="reviewer" + title="Approvals and proposals from this console are attributed to this name (empty falls back to 'console')" + className="w-28 rounded-lg border border-rule bg-paper-2 px-2 py-1.5 text-xs text-ink-2 outline-none focus:border-accent" + />