Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions webapp/plugins/vouch-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
)
Expand Down Expand Up @@ -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 },
Expand Down
4 changes: 4 additions & 0 deletions webapp/plugins/vouch-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export function proxyMiddleware(): (req: IncomingMessage, res: ServerResponse, n
const headers: Record<string, string> = {}
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(
{
Expand Down
13 changes: 13 additions & 0 deletions webapp/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -127,6 +129,17 @@ export function Shell() {
))}
</select>
)}
<input
aria-label="reviewer identity"
value={reviewer}
onChange={(e) => {
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"
/>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title="Toggle theme"
Expand Down
50 changes: 49 additions & 1 deletion webapp/src/lib/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { afterEach, expect, test, vi } from 'vitest'
import { fetchCapabilities, fetchHealth, rpc, VouchHttpError, VouchRpcError } from './rpc'
import {
DEFAULT_REVIEWER,
fetchCapabilities,
fetchHealth,
REVIEWER_KEY,
rpc,
setReviewerId,
VouchHttpError,
VouchRpcError,
} from './rpc'
import type { VouchConnectionInfo } from './types'

const conn: VouchConnectionInfo = { endpoint: 'http://127.0.0.1:8731', token: 'tok' }
Expand Down Expand Up @@ -29,6 +38,45 @@ test('rpc posts the envelope to /proxy/rpc with target + bearer headers and unwr
expect(sent.id).toMatch(/^ui-\d+$/)
})

test('rpc sends x-vouch-agent defaulting to the console reviewer', async () => {
localStorage.removeItem(REVIEWER_KEY)
const fn = mockFetch(200, { id: 'x', ok: true, result: {} })
await rpc(conn, 'kb.status')
expect(fn.mock.calls[0][1].headers['x-vouch-agent']).toBe(DEFAULT_REVIEWER)
})

test('setReviewerId is trimmed and sent as x-vouch-agent on later calls', async () => {
setReviewerId(' alice ')
const fn = mockFetch(200, { id: 'x', ok: true, result: {} })
await rpc(conn, 'kb.approve', { proposal_id: 'p1' })
expect(fn.mock.calls[0][1].headers['x-vouch-agent']).toBe('alice')
localStorage.removeItem(REVIEWER_KEY)
})

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')
Expand Down
51 changes: 49 additions & 2 deletions webapp/src/lib/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,34 @@ export class VouchHttpError extends Error {

let seq = 0

// The identity this console acts as. Sent on every call as X-Vouch-Agent so
// approvals/proposals are attributed to a human reviewer rather than the
// tokenless `unknown-agent` default — the server derives approved_by from it,
// and a distinct reviewer no longer self-collides with the proposing agent.
export const REVIEWER_KEY = 'vouch-ui.reviewer.v1'
export const DEFAULT_REVIEWER = 'console'

export function reviewerId(): string {
try {
return localStorage.getItem(REVIEWER_KEY)?.trim() || DEFAULT_REVIEWER
} catch {
return DEFAULT_REVIEWER
}
}

export function setReviewerId(id: string): void {
try {
localStorage.setItem(REVIEWER_KEY, id.trim())
} catch {
/* private-mode / storage-disabled: fall back to the default reviewer */
}
}

function baseHeaders(conn: VouchConnectionInfo): Record<string, string> {
const h: Record<string, string> = { 'x-vouch-target': conn.endpoint }
const h: Record<string, string> = {
'x-vouch-target': conn.endpoint,
'x-vouch-agent': reviewerId(),
}
if (conn.token) h.authorization = `Bearer ${conn.token}`
return h
}
Expand All @@ -44,7 +70,28 @@ export async function rpc<T>(
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<T>(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<boolean> {
Expand Down
Loading