diff --git a/control-api/src/config.ts b/control-api/src/config.ts index 44d16d66b7..a505a34933 100644 --- a/control-api/src/config.ts +++ b/control-api/src/config.ts @@ -240,6 +240,7 @@ type Config = { // It is deliberately separate from the capacity-impacting wake budget. hostArtifactReadRlPerMin: number hostMessageRlPerMin: number + hostRpcAdmissionRlPerMin: number hostWakeCoalesceWindowMs: number approvalMediumChallengeTtlSec: number telegramProviderEventChallengeTtlSec: number @@ -1183,6 +1184,10 @@ export const config: Config = { // list and download reads across rpc-proxy replicas. hostArtifactReadRlPerMin: 30, hostMessageRlPerMin: positiveIntegerFromEnv('CONTROL_API_HOST_MESSAGE_RL_PER_MIN', 60), + hostRpcAdmissionRlPerMin: positiveIntegerFromEnv( + 'CONTROL_API_HOST_RPC_ADMISSION_RL_PER_MIN', + 300 + ), hostWakeCoalesceWindowMs: Number(process.env.CONTROL_API_HOST_WAKE_COALESCE_WINDOW_MS || 2000), approvalMediumChallengeTtlSec: Number( process.env.WORKFLOW_APPROVAL_MEDIUM_CHALLENGE_TTL_SEC || 60 * 60 diff --git a/control-api/src/routes/internal/actionAuthorityCheckpoint.ts b/control-api/src/routes/internal/actionAuthorityCheckpoint.ts index ec734db8bc..7e371dbdb5 100644 --- a/control-api/src/routes/internal/actionAuthorityCheckpoint.ts +++ b/control-api/src/routes/internal/actionAuthorityCheckpoint.ts @@ -11,6 +11,11 @@ import { admitHostMessage, respondHostMessageAdmissionFailure, } from '../../services/hostMessageAdmission.js' +import { + admitHostRpc, + requiresHostRpcAdmission, + respondHostRpcAdmissionFailure, +} from '../../services/hostRpcAdmission.js' import { issueHostMessageAdmissionReceipt, verifyHostMessageAdmissionReceipt, @@ -81,6 +86,13 @@ export function createInternalActionAuthorityCheckpointRouter(gateway: K8sGatewa } } } + if (requiresHostRpcAdmission(parsed.operationId)) { + const admission = await admitHostRpc(parsed.principal.sub) + if (admission.status !== 'allowed') { + respondHostRpcAdmissionFailure(res, admission) + return + } + } const budget = AccessExecutionBudget.create('action') try { const result = await checkpointActionAuthority({ diff --git a/control-api/src/routes/rpc-access/users.ts b/control-api/src/routes/rpc-access/users.ts index 71e57835e3..5b2c9c9510 100644 --- a/control-api/src/routes/rpc-access/users.ts +++ b/control-api/src/routes/rpc-access/users.ts @@ -26,6 +26,7 @@ import { admitHostMessage, respondHostMessageAdmissionFailure, } from '../../services/hostMessageAdmission.js' +import { admitHostRpc, respondHostRpcAdmissionFailure } from '../../services/hostRpcAdmission.js' import { type DirectRunAttributionBindingService, DirectRunBindingConflictError, @@ -270,6 +271,48 @@ export function createRpcAccessUsersRouter( // host claim, a live user/team directory grant, and an enabled Host CR. const hostAccessPath = '/rpc/access/users/:userId/mcp-hosts/:hostRef' + // Internal, operation-agnostic adapter for legacy non-message Host routes. + // The signed token supplies the stable subject; user/Host selectors are + // checked against its claims but never enter the shared bucket key. + router.post( + `${hostAccessPath}/host-rpc-admission`, + requireValidRpcAccessTokenAny([ + 'host:message:invoke', + 'host:wake:write', + 'host:status:read', + 'host:health:read', + 'host:activity:read', + 'host:approval:write', + 'host:model:write', + 'host:task:read', + 'host:session:read', + 'host:session:write', + ]), + requireRpcTokenUserMatch(), + requireRpcTokenHostMatch(), + async (req: RpcAuthedRequest, res, next) => { + try { + if (Number(req.headers['content-length'] || 0) > 0 || req.headers['transfer-encoding']) { + res.status(400).json({ error: 'request body is not allowed' }) + return + } + const claims = req.rpcAuth + if (!claims) { + res.status(403).json({ error: 'Forbidden' }) + return + } + const admission = await admitHostRpc(claims.sub) + if (admission.status !== 'allowed') { + respondHostRpcAdmissionFailure(res, admission) + return + } + res.status(204).end() + } catch (error) { + next(error) + } + } + ) + router.get( hostAccessPath, requireValidRpcAccessTokenAny([...HOST_ACCESS_SCOPES]), diff --git a/control-api/src/services/hostMessageAdmission.ts b/control-api/src/services/hostMessageAdmission.ts index cc510e695e..511c24880d 100644 --- a/control-api/src/services/hostMessageAdmission.ts +++ b/control-api/src/services/hostMessageAdmission.ts @@ -1,15 +1,16 @@ -import type { Response } from 'express' import { config } from '../config.js' import { type RateLimitCheck, checkAndIncrement } from './rateLimiterService.js' +import { + type StrictFixedWindowAdmission, + admitStrictFixedWindow, + respondStrictFixedWindowFailure, +} from './strictFixedWindowAdmission.js' export function hostMessageAdmissionBucketKey(verifiedSubject: string): string { return `host-message-admission:${verifiedSubject}` } -export type HostMessageAdmission = - | { status: 'allowed'; check: RateLimitCheck } - | { status: 'limited'; check: RateLimitCheck } - | { status: 'unavailable' } +export type HostMessageAdmission = StrictFixedWindowAdmission /** Shared authority for legacy messages and the later v2 chat.message.invoke checkpoint. */ export async function admitHostMessage( @@ -17,39 +18,22 @@ export async function admitHostMessage( check: typeof checkAndIncrement = checkAndIncrement ): Promise { if (!verifiedSubject) return { status: 'unavailable' } - let result: RateLimitCheck - try { - result = await check(hostMessageAdmissionBucketKey(verifiedSubject), config.hostMessageRlPerMin) - } catch { - return { status: 'unavailable' } - } - if ( - !result || - result.backendAvailable !== true || - typeof result.allowed !== 'boolean' || - !Number.isSafeInteger(result.count) || - result.count < 1 || - !Number.isSafeInteger(result.resetMs) || - result.allowed !== result.count <= config.hostMessageRlPerMin - ) { - return { status: 'unavailable' } - } - return { status: result.allowed ? 'allowed' : 'limited', check: result } + return admitStrictFixedWindow( + hostMessageAdmissionBucketKey(verifiedSubject), + config.hostMessageRlPerMin, + check + ) } /** The same response contract can be used by the PR2 checkpoint after cascade. */ export function respondHostMessageAdmissionFailure( - res: Response, + res: import('express').Response, admission: Exclude ): void { - if (admission.status === 'unavailable') { - res.status(503).json({ error: 'host_message_admission_unavailable' }) - return - } - const retryAfterSeconds = Math.max(1, Math.ceil((admission.check.resetMs - Date.now()) / 1000)) - res.setHeader('Retry-After', String(retryAfterSeconds)) - res.setHeader('X-RateLimit-Limit', String(config.hostMessageRlPerMin)) - res.setHeader('X-RateLimit-Remaining', '0') - res.setHeader('X-RateLimit-Reset', String(Math.floor(admission.check.resetMs / 1000))) - res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds }) + respondStrictFixedWindowFailure( + res, + admission, + 'host_message_admission_unavailable', + config.hostMessageRlPerMin + ) } diff --git a/control-api/src/services/hostRpcAdmission.ts b/control-api/src/services/hostRpcAdmission.ts new file mode 100644 index 0000000000..adffa62678 --- /dev/null +++ b/control-api/src/services/hostRpcAdmission.ts @@ -0,0 +1,57 @@ +import { config } from '../config.js' +import { checkAndIncrementStrict } from './rateLimiterService.js' +import { + type StrictFixedWindowAdmission, + admitStrictFixedWindow, + respondStrictFixedWindowFailure, +} from './strictFixedWindowAdmission.js' + +export const HOST_RPC_ADMISSION_BUCKET_PREFIX = 'host-rpc-admission:' + +/** Closed checkpoint operation set approved by Task 106 Spec 65. */ +const HOST_RPC_ADMISSION_OPERATIONS = new Set([ + 'host.wake', + 'task.manage', + 'task.read', + 'session.read', + 'session.manage', + 'model.read', + 'model.select', + 'host.activity.read', + 'host.activity.read_all', + 'host.status.read', + 'host.health.read', +]) + +export function requiresHostRpcAdmission(operationId: string): boolean { + return HOST_RPC_ADMISSION_OPERATIONS.has(operationId) +} + +export function hostRpcAdmissionBucketKey(verifiedSubject: string): string { + return `${HOST_RPC_ADMISSION_BUCKET_PREFIX}${verifiedSubject}` +} + +/** One durable, subject-wide budget shared by legacy Host routes and v2 checkpoints. */ +export async function admitHostRpc( + verifiedSubject: string, + check: typeof checkAndIncrementStrict = checkAndIncrementStrict +): Promise { + if (!verifiedSubject) return { status: 'unavailable' } + return admitStrictFixedWindow( + hostRpcAdmissionBucketKey(verifiedSubject), + config.hostRpcAdmissionRlPerMin, + check + ) +} + +export function respondHostRpcAdmissionFailure( + res: import('express').Response, + admission: Exclude +): void { + respondStrictFixedWindowFailure( + res, + admission, + 'host_rpc_admission_unavailable', + config.hostRpcAdmissionRlPerMin + ) +} diff --git a/control-api/src/services/rateLimiterService.ts b/control-api/src/services/rateLimiterService.ts index 5ee268d781..b4b1714fe4 100644 --- a/control-api/src/services/rateLimiterService.ts +++ b/control-api/src/services/rateLimiterService.ts @@ -22,9 +22,7 @@ import { boundedBucketKey } from './rateLimitBucketKey.js' */ const WINDOW_MS = 60_000 - -// One rate_limit_db_error line per bucket key per window. Keys come from -// clients, so the throttle also caps how many distinct keys it tracks. +export const RATE_LIMIT_BACKEND_RETRY_AFTER_SECONDS = 2 const dbErrorLogThrottle = new LogThrottle(WINDOW_MS) export type RateLimitCheck = { @@ -36,19 +34,16 @@ export type RateLimitCheck = { backendAvailable: boolean } -/** - * Retry-After sent by the callers that fail closed with 503 when - * `backendAvailable` is false. The count is unknown in that case, so no - * window reset can be computed; a short fixed hint spreads the retries. - */ -export const RATE_LIMIT_BACKEND_RETRY_AFTER_SECONDS = 2 - export function currentWindowStartMs(nowMs = Date.now()): number { return Math.floor(nowMs / WINDOW_MS) * WINDOW_MS } type RateLimitQuery = (text: string, values?: unknown[]) => Promise<{ rows: unknown[] }> +function assertPositiveCost(cost: number): void { + if (!Number.isSafeInteger(cost) || cost < 1) throw new Error('rate limit cost must be positive') +} + /** * Atomically increment the (bucketKey, windowStart) counter, return the new * count plus whether it's still under `maxPerMinute`. @@ -75,7 +70,7 @@ export async function checkAndIncrement( /** * Same atomic limiter operation against an explicitly supplied query - * function. Production callers use the dedicated limiter pool above; the narrow + * function. Production callers use the process-wide pool above; the narrow * seam lets the real-Postgres integration suite exercise this exact SQL * against an isolated database without changing runtime ownership. */ @@ -86,34 +81,13 @@ export async function checkAndIncrementWithQuery( nowMs = Date.now(), cost = 1 ): Promise { - if (!Number.isSafeInteger(cost) || cost < 1) throw new Error('rate limit cost must be positive') + assertPositiveCost(cost) const windowStartMs = currentWindowStartMs(nowMs) const resetMs = windowStartMs + WINDOW_MS - // Callers that build keys from client input without calling the middleware - // get the same bound; an already bounded key is returned unchanged. const storedKey = boundedBucketKey(bucketKey) - - let result: { rows: unknown[] } | null | undefined try { - result = await query( - `INSERT INTO rate_limit_buckets (bucket_key, window_start_ms, count) - VALUES ($1, $2, $3) - ON CONFLICT (bucket_key, window_start_ms) DO UPDATE - SET count = rate_limit_buckets.count + EXCLUDED.count - RETURNING count`, - [storedKey, windowStartMs, cost] - ) + return await incrementFixedWindowWithQuery(query, bucketKey, maxPerMinute, nowMs, cost) } catch (err) { - // The service does not decide admission. It reports backendAvailable:false - // and each caller decides: rateLimitMiddleware answers 503 or counts the - // request in its process-memory fallback, and the external GFS limiter - // answers 503. - // Bucket keys carry user and workload ids; the log gets the same SHA-256 - // the external GFS limiter logs as hashedKey, so the lines still join. It - // is a correlation id, not anonymization: unsalted, so a low-entropy key - // (an IP, an email) can be recovered by guessing. While the backend is - // down every request lands here, so the line is throttled per bucket key - // and the counter keeps the full count. rateLimitBackendErrorsTotal.inc() const hashedKey = createHash('sha256').update(storedKey).digest('hex') const suppressed = dbErrorLogThrottle.admit(hashedKey) @@ -137,20 +111,86 @@ export async function checkAndIncrementWithQuery( backendAvailable: false, } } - // No row (e.g. a test harness mocking pool.query with empty rows) is also - // reported as backendAvailable:false; the caller decides, as above. +} + +/** Strict counterpart for security boundaries; it shares the exact atomic SQL. */ +export async function checkAndIncrementStrict( + bucketKey: string, + maxPerMinute: number, + nowMs = Date.now(), + cost = 1 +): Promise { + return checkAndIncrementStrictWithQuery( + (text, values) => rateLimitPool.query(text, values), + bucketKey, + maxPerMinute, + nowMs, + cost + ) +} + +/** Strict explicit-query seam for real PostgreSQL and store-failure proofs. */ +export async function checkAndIncrementStrictWithQuery( + query: RateLimitQuery, + bucketKey: string, + maxPerMinute: number, + nowMs = Date.now(), + cost = 1 +): Promise { + assertPositiveCost(cost) + try { + return await incrementFixedWindowWithQuery(query, bucketKey, maxPerMinute, nowMs, cost) + } catch (err) { + reportStrictRateLimitBackendError(bucketKey, err) + throw err + } +} + +function reportStrictRateLimitBackendError(bucketKey: string, err: unknown): void { + rateLimitBackendErrorsTotal.inc() + const hashedKey = createHash('sha256').update(boundedBucketKey(bucketKey)).digest('hex') + const suppressed = dbErrorLogThrottle.admit(hashedKey) + if (suppressed === undefined) return + + const errorName = err instanceof Error ? 'Error' : 'UnknownError' + const errorCode = + err && + typeof err === 'object' && + 'code' in err && + typeof err.code === 'string' && + /^[0-9A-Z]{5}$/.test(err.code) + ? err.code + : undefined + rootLogger.warn( + { event: 'rate_limit_db_error', hashedKey, errorName, errorCode, suppressed }, + 'strict rate limiter DB error, backend unavailable' + ) +} + +async function incrementFixedWindowWithQuery( + query: RateLimitQuery, + bucketKey: string, + maxPerMinute: number, + nowMs: number, + cost: number +): Promise { + if (!Number.isSafeInteger(maxPerMinute) || maxPerMinute < 1) { + throw new Error('rate limit must be a positive safe integer') + } + const windowStartMs = currentWindowStartMs(nowMs) + const resetMs = windowStartMs + WINDOW_MS + const storedKey = boundedBucketKey(bucketKey) + const result = await query( + `INSERT INTO rate_limit_buckets (bucket_key, window_start_ms, count) + VALUES ($1, $2, $3) + ON CONFLICT (bucket_key, window_start_ms) DO UPDATE + SET count = rate_limit_buckets.count + EXCLUDED.count + RETURNING count`, + [storedKey, windowStartMs, cost] + ) const rows = result && Array.isArray(result.rows) ? result.rows : [] const row = (rows[0] ?? null) as { count: number | string } | null - if (!row) { - return { - allowed: true, - remaining: maxPerMinute, - resetMs, - windowStartMs, - count: 0, - backendAvailable: false, - } - } + if (!row) throw new Error('rate limit store returned no admission state') const count = Number(row.count) const allowed = count <= maxPerMinute const remaining = Math.max(0, maxPerMinute - count) @@ -220,10 +260,6 @@ function advisoryKey(value: string): AdvisoryLockKey { return { high: digest.readInt32BE(0), low: digest.readInt32BE(4) } } -// The advisory-lock client stays on the core pool: advisory locks write no WAL, -// so synchronous_commit=off buys nothing, and this client is held for the life -// of the process, which would take one of the limiter pool's few connections -// permanently. async function getConcurrencyClient(): Promise { if (concurrencyClient) return concurrencyClient if (!concurrencyClientPromise) { diff --git a/control-api/src/services/strictFixedWindowAdmission.ts b/control-api/src/services/strictFixedWindowAdmission.ts new file mode 100644 index 0000000000..2c48af09a0 --- /dev/null +++ b/control-api/src/services/strictFixedWindowAdmission.ts @@ -0,0 +1,58 @@ +import type { Response } from 'express' +import { type RateLimitCheck, checkAndIncrementStrict } from './rateLimiterService.js' + +export type StrictFixedWindowAdmission = + | { status: 'allowed'; check: RateLimitCheck } + | { status: 'limited'; check: RateLimitCheck } + | { status: 'unavailable' } + +/** + * Security-boundary wrapper for the shared fixed-window SQL primitive. + * The generic primitive intentionally fails open; strict callers must reject + * missing, malformed, or backend-unavailable results instead. + */ +export async function admitStrictFixedWindow( + bucketKey: string, + limit: number, + checkAndIncrementImpl: typeof checkAndIncrementStrict = checkAndIncrementStrict +): Promise { + if (!bucketKey || !Number.isSafeInteger(limit) || limit < 1) { + return { status: 'unavailable' } + } + let result: RateLimitCheck + try { + result = await checkAndIncrementImpl(bucketKey, limit) + } catch { + return { status: 'unavailable' } + } + if ( + !result || + result.backendAvailable !== true || + typeof result.allowed !== 'boolean' || + !Number.isSafeInteger(result.count) || + result.count < 1 || + !Number.isSafeInteger(result.resetMs) || + result.allowed !== result.count <= limit + ) { + return { status: 'unavailable' } + } + return { status: result.allowed ? 'allowed' : 'limited', check: result } +} + +export function respondStrictFixedWindowFailure( + res: Response, + admission: Exclude, + unavailableError: string, + limit: number +): void { + if (admission.status === 'unavailable') { + res.status(503).json({ error: unavailableError }) + return + } + const retryAfterSeconds = Math.max(1, Math.ceil((admission.check.resetMs - Date.now()) / 1000)) + res.setHeader('Retry-After', String(retryAfterSeconds)) + res.setHeader('X-RateLimit-Limit', String(limit)) + res.setHeader('X-RateLimit-Remaining', '0') + res.setHeader('X-RateLimit-Reset', String(Math.floor(admission.check.resetMs / 1000))) + res.status(429).json({ error: 'Too Many Requests', retryAfterSeconds }) +} diff --git a/control-api/test/config.hostRpcAdmission.test.ts b/control-api/test/config.hostRpcAdmission.test.ts new file mode 100644 index 0000000000..c1aacb2673 --- /dev/null +++ b/control-api/test/config.hostRpcAdmission.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +async function loadConfig(raw?: string) { + const original = process.env.CONTROL_API_HOST_RPC_ADMISSION_RL_PER_MIN + if (raw === undefined) delete process.env.CONTROL_API_HOST_RPC_ADMISSION_RL_PER_MIN + else process.env.CONTROL_API_HOST_RPC_ADMISSION_RL_PER_MIN = raw + vi.resetModules() + try { + return (await import('../src/config.js')).config + } finally { + if (original === undefined) delete process.env.CONTROL_API_HOST_RPC_ADMISSION_RL_PER_MIN + else process.env.CONTROL_API_HOST_RPC_ADMISSION_RL_PER_MIN = original + } +} + +describe('Host-RPC admission configuration', () => { + afterEach(() => vi.resetModules()) + + it('defaults to 300 per fixed minute and accepts positive finite integer overrides', async () => { + expect((await loadConfig()).hostRpcAdmissionRlPerMin).toBe(300) + expect((await loadConfig('3')).hostRpcAdmissionRlPerMin).toBe(3) + }) + + it.each(['0', '-1', '1.5', 'Infinity', '9007199254740992'])( + 'rejects invalid override %s without an unlimited bypass', + async raw => { + await expect(loadConfig(raw)).rejects.toThrow( + 'CONTROL_API_HOST_RPC_ADMISSION_RL_PER_MIN must be a positive integer' + ) + } + ) +}) diff --git a/control-api/test/fixtures/emitRuntimeSessionDelegationV2Fixture.ts b/control-api/test/fixtures/emitRuntimeSessionDelegationV2Fixture.ts index ee6704af14..a53855f670 100644 --- a/control-api/test/fixtures/emitRuntimeSessionDelegationV2Fixture.ts +++ b/control-api/test/fixtures/emitRuntimeSessionDelegationV2Fixture.ts @@ -18,6 +18,12 @@ async function main(): Promise { provider: 'openai', model: 'model-a', }, + 'session.manage': { + hostRef: 'mcp-host/chatllm', + agent: 'agent-a', + chatId: 'chat-a', + action: 'rename', + }, } const operationTarget = targets[operationId] if (!operationTarget) throw new Error('runtime_session_operation_required') diff --git a/control-api/test/hostRpcAdmission.test.ts b/control-api/test/hostRpcAdmission.test.ts new file mode 100644 index 0000000000..9ca4adb401 --- /dev/null +++ b/control-api/test/hostRpcAdmission.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { config } from '../src/config.js' + +const limiter = vi.hoisted(() => ({ checkAndIncrementStrict: vi.fn() })) +vi.mock('../src/services/rateLimiterService.js', () => limiter) + +const { admitHostRpc, hostRpcAdmissionBucketKey, requiresHostRpcAdmission } = + await import('../src/services/hostRpcAdmission.js') + +function admittedResult(count: number, limit = config.hostRpcAdmissionRlPerMin) { + return { + allowed: count <= limit, + remaining: Math.max(0, limit - count), + resetMs: Date.now() + 30_000, + windowStartMs: Date.now(), + count, + backendAvailable: true, + } +} + +describe('Spec 65 Host-RPC admission authority', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses only the verified subject in one shared bucket', async () => { + limiter.checkAndIncrementStrict.mockResolvedValue(admittedResult(1)) + expect(hostRpcAdmissionBucketKey('subject-a')).toBe('host-rpc-admission:subject-a') + expect(await admitHostRpc('subject-a')).toMatchObject({ status: 'allowed' }) + expect(limiter.checkAndIncrementStrict).toHaveBeenCalledWith( + 'host-rpc-admission:subject-a', + 300 + ) + }) + + it('keeps message, artifact, and unrelated checkpoint operations outside this budget', () => { + expect(requiresHostRpcAdmission('chat.message.invoke')).toBe(false) + expect(requiresHostRpcAdmission('workflow.artifact.read')).toBe(false) + expect(requiresHostRpcAdmission('remote_desktop.open')).toBe(false) + expect(requiresHostRpcAdmission('host.wake')).toBe(true) + }) + + it('allows N and denies N+1 with the configured positive ceiling', async () => { + const mutableConfig = config as typeof config & { hostRpcAdmissionRlPerMin: number } + const original = mutableConfig.hostRpcAdmissionRlPerMin + mutableConfig.hostRpcAdmissionRlPerMin = 3 + limiter.checkAndIncrementStrict.mockImplementation(async (_key: string, limit: number) => { + const count = limiter.checkAndIncrementStrict.mock.calls.length + return admittedResult(count, limit) + }) + try { + expect((await admitHostRpc('subject-a')).status).toBe('allowed') + expect((await admitHostRpc('subject-a')).status).toBe('allowed') + expect((await admitHostRpc('subject-a')).status).toBe('allowed') + expect((await admitHostRpc('subject-a')).status).toBe('limited') + } finally { + mutableConfig.hostRpcAdmissionRlPerMin = original + } + }) + + it('fails closed on thrown, missing, backend-unavailable, or inconsistent store state', async () => { + limiter.checkAndIncrementStrict.mockRejectedValueOnce(new Error('db unavailable')) + expect(await admitHostRpc('subject-a')).toEqual({ status: 'unavailable' }) + + limiter.checkAndIncrementStrict.mockResolvedValueOnce(undefined) + expect(await admitHostRpc('subject-a')).toEqual({ status: 'unavailable' }) + + limiter.checkAndIncrementStrict.mockResolvedValueOnce({ + ...admittedResult(1), + backendAvailable: false, + }) + expect(await admitHostRpc('subject-a')).toEqual({ status: 'unavailable' }) + + limiter.checkAndIncrementStrict.mockResolvedValueOnce({ + ...admittedResult(1), + allowed: false, + }) + expect(await admitHostRpc('subject-a')).toEqual({ status: 'unavailable' }) + }) +}) diff --git a/control-api/test/hostRpcAdmissionCheckpoint.test.ts b/control-api/test/hostRpcAdmissionCheckpoint.test.ts new file mode 100644 index 0000000000..ad9936ee71 --- /dev/null +++ b/control-api/test/hostRpcAdmissionCheckpoint.test.ts @@ -0,0 +1,421 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' +import type { ActionOperationId } from '@clerum/action-context-contracts' +import { actionAuthorityCheckpointRequest } from '../../rpc-proxy/src/actionAuthorityV2.js' +import { config as rpcProxyConfig } from '../../rpc-proxy/src/config.js' +import { bindRouteActionV2 } from '../../rpc-proxy/src/routeActionBindingV2.js' +import { config } from '../src/config.js' +import { createRpcAccessUsersRouter } from '../src/routes/rpc-access/users.js' +import { prepareActionOperationTarget } from '../src/services/access/actionMessageId.js' +import { canonicalResourceIdentity } from '../src/services/access/resourceIdentity.js' +import { signRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' +import { + issueUserDelegationV2, + verifyUserDelegationV2, +} from '../src/utils/auth/userDelegationV2Token.js' + +const admission = vi.hoisted(() => ({ checkAndIncrement: vi.fn() })) +const checkpoint = vi.hoisted(() => ({ checkpointActionAuthority: vi.fn() })) + +vi.mock('../src/services/rateLimiterService.js', () => ({ + checkAndIncrement: admission.checkAndIncrement, + checkAndIncrementStrict: admission.checkAndIncrement, +})) +vi.mock('../src/services/access/actionAuthorityCheckpoint.js', async importOriginal => ({ + ...(await importOriginal()), + checkpointActionAuthority: checkpoint.checkpointActionAuthority, +})) +vi.mock('../src/middleware/actionCheckpointCaller.js', () => ({ + requireActionCheckpointCaller: ( + req: express.Request, + _res: express.Response, + next: express.NextFunction + ) => { + req.actionCheckpointCaller = { + service: 'rpc-proxy', + trustPlane: 'internal_service_token', + } + next() + }, +})) + +const { createInternalActionAuthorityCheckpointRouter } = + await import('../src/routes/internal/actionAuthorityCheckpoint.js') + +const SUBJECT = '10000000-0000-4000-8000-000000000001' +const SID = '20000000-0000-4000-8000-000000000002' +const TASK_ID = '40000000-0000-4000-8000-000000000001' +const APPROVAL_ID = '40000000-0000-4000-8000-000000000002' +const CHAT_ID = '40000000-0000-4000-8000-000000000003' +const HOST_REF = `${rpcProxyConfig.hostNamespace}/chatllm` +const HOST_RESOURCE = canonicalResourceIdentity({ + environmentId: 'development:local', + type: 'host', + logicalId: HOST_REF, +}) + +type ProducerRoute = Readonly<{ + operationId: ActionOperationId + resourceType: 'host' | 'runtime_session' + path: string + method: string + params?: Record + query?: Record + body?: Record + target: Record +}> + +const IN_SCOPE_ROUTES: readonly ProducerRoute[] = [ + { + operationId: 'host.wake', + resourceType: 'host', + path: '/rpc/hosts/:hostRef/wake', + method: 'POST', + body: { wakeReason: 'explicit' }, + target: { hostRef: HOST_REF, wakeReason: 'explicit' }, + }, + { + operationId: 'task.manage', + resourceType: 'runtime_session', + path: '/rpc/hosts/:hostRef/approvals/approve', + method: 'POST', + body: { taskId: TASK_ID, toolCallId: APPROVAL_ID }, + target: { + hostRef: HOST_REF, + taskId: TASK_ID, + action: 'approve', + approvalRequestId: APPROVAL_ID, + }, + }, + { + operationId: 'task.manage', + resourceType: 'runtime_session', + path: '/rpc/hosts/:hostRef/tasks/:taskId/cancel', + method: 'POST', + params: { hostRef: 'chatllm', taskId: TASK_ID }, + target: { hostRef: HOST_REF, taskId: TASK_ID, action: 'cancel' }, + }, + { + operationId: 'task.read', + resourceType: 'runtime_session', + path: '/rpc/hosts/:hostRef/tasks/:taskId/result', + method: 'GET', + params: { hostRef: 'chatllm', taskId: TASK_ID }, + target: { hostRef: HOST_REF, taskId: TASK_ID }, + }, + { + operationId: 'session.read', + resourceType: 'runtime_session', + path: '/rpc/hosts/:hostRef/sessions/search', + method: 'GET', + params: { hostRef: 'chatllm' }, + query: { q: 'latest' }, + target: { hostRef: HOST_REF }, + }, + { + operationId: 'session.manage', + resourceType: 'runtime_session', + path: '/rpc/hosts/:hostRef/sessions/:agent/:chatId/name', + method: 'PATCH', + params: { hostRef: 'chatllm', agent: 'chatllm', chatId: CHAT_ID }, + body: { name: 'Renamed' }, + target: { hostRef: HOST_REF, agent: 'chatllm', chatId: CHAT_ID, action: 'rename' }, + }, + { + operationId: 'model.read', + resourceType: 'runtime_session', + path: '/rpc/hosts/:hostRef/models', + method: 'GET', + params: { hostRef: 'chatllm' }, + query: { agent: 'chatllm', chatId: CHAT_ID }, + target: { hostRef: HOST_REF, agent: 'chatllm', chatId: CHAT_ID }, + }, + { + operationId: 'model.select', + resourceType: 'runtime_session', + path: '/rpc/hosts/:hostRef/model', + method: 'POST', + params: { hostRef: 'chatllm' }, + body: { agent: 'chatllm', chatId: CHAT_ID, provider: 'openai', model: 'model-a' }, + target: { + hostRef: HOST_REF, + agent: 'chatllm', + chatId: CHAT_ID, + provider: 'openai', + model: 'model-a', + }, + }, + { + operationId: 'host.activity.read', + resourceType: 'host', + path: '/rpc/hosts/:hostRef/activity', + method: 'GET', + params: { hostRef: 'chatllm' }, + target: { hostRef: HOST_REF, visibility: 'caller_path' }, + }, + { + operationId: 'host.activity.read_all', + resourceType: 'host', + path: '/rpc/hosts/:hostRef/activity', + method: 'GET', + params: { hostRef: 'chatllm' }, + query: { visibility: 'host_all' }, + target: { hostRef: HOST_REF, visibility: 'host_all' }, + }, + { + operationId: 'host.status.read', + resourceType: 'host', + path: '/rpc/hosts/:hostRef/status/stream', + method: 'GET', + params: { hostRef: 'chatllm' }, + target: { hostRef: HOST_REF }, + }, + { + operationId: 'host.health.read', + resourceType: 'host', + path: '/rpc/hosts/:hostRef/health', + method: 'GET', + params: { hostRef: 'chatllm' }, + target: { hostRef: HOST_REF }, + }, +] + +function checkpointFor(route: ProducerRoute) { + const resource = + route.resourceType === 'host' + ? HOST_RESOURCE + : canonicalResourceIdentity({ + environmentId: 'development:local', + type: 'runtime_session', + logicalId: 'chatllm/session-a', + }) + const prepared = prepareActionOperationTarget({ + operationId: route.operationId, + resource, + operationTarget: route.target, + }) + const token = issueUserDelegationV2({ + principal: { userId: SUBJECT, sid: SID, sessionVersion: 1 }, + operationIds: [route.operationId], + resource, + preparedTargets: { [route.operationId]: prepared }, + accessPathId: `ap1_${'a'.repeat(43)}`, + authorizationRevision: `ar1_${'b'.repeat(43)}`, + behaviorBindingHash: `bh2_${'c'.repeat(43)}`, + pathKind: 'direct', + effectiveTeamId: null, + }) + const claims = verifyUserDelegationV2(token) + if (!claims) throw new Error('producer delegation failed verification') + const bound = bindRouteActionV2( + { + route: { path: route.path }, + method: route.method, + params: route.params ?? { hostRef: 'chatllm' }, + query: route.query ?? {}, + body: route.body, + userDelegationV2: claims, + } as never, + claims + ) + return actionAuthorityCheckpointRequest(claims, bound) +} + +function app() { + const server = express() + server.use(express.json()) + server.use( + createRpcAccessUsersRouter({ listResource: vi.fn(async () => []) } as never, { + bindingService: { bind: vi.fn(async () => ({ status: 'created' })) }, + }) + ) + server.use(createInternalActionAuthorityCheckpointRouter({} as never)) + return server +} + +describe('Spec 65 v2 Host-RPC admission checkpoint', () => { + beforeEach(() => { + vi.clearAllMocks() + admission.checkAndIncrement.mockImplementation(async (_key: string, limit: number) => ({ + allowed: true, + remaining: limit - 1, + resetMs: Date.now() + 30_000, + windowStartMs: Date.now(), + count: 1, + backendAvailable: true, + })) + checkpoint.checkpointActionAuthority.mockResolvedValue({ status: 'allowed' }) + }) + + it('charges every approved non-message operation through one subject bucket', async () => { + const server = app() + for (const route of IN_SCOPE_ROUTES) { + await request(server) + .post('/internal/action-authority/checkpoint') + .send(checkpointFor(route)) + .expect(200) + } + expect(admission.checkAndIncrement).toHaveBeenCalledTimes(IN_SCOPE_ROUTES.length) + expect(new Set(admission.checkAndIncrement.mock.calls.map(call => call[0]))).toEqual( + new Set([`host-rpc-admission:${SUBJECT}`]) + ) + }) + + it('rejects request N+1 before the live checkpoint using the configured test ceiling', async () => { + const admissionConfig = config as typeof config & { hostRpcAdmissionRlPerMin: number } + const originalLimit = admissionConfig.hostRpcAdmissionRlPerMin + admissionConfig.hostRpcAdmissionRlPerMin = 3 + const counts = new Map() + admission.checkAndIncrement.mockImplementation(async (key: string, limit: number) => { + const count = (counts.get(key) ?? 0) + 1 + counts.set(key, count) + return { + allowed: count <= limit, + remaining: Math.max(0, limit - count), + resetMs: Date.now() + 30_000, + windowStartMs: Date.now(), + count, + backendAvailable: true, + } + }) + const server = app() + try { + const route = IN_SCOPE_ROUTES.find(entry => entry.operationId === 'host.wake')! + for (let index = 0; index < 3; index += 1) { + await request(server) + .post('/internal/action-authority/checkpoint') + .send(checkpointFor(route)) + .expect(200) + } + const liveCalls = checkpoint.checkpointActionAuthority.mock.calls.length + const denied = await request(server) + .post('/internal/action-authority/checkpoint') + .send(checkpointFor(route)) + .expect(429) + expect(denied.headers['retry-after']).toMatch(/^\d+$/) + expect(denied.headers['x-ratelimit-limit']).toBe('3') + expect(denied.headers['x-ratelimit-remaining']).toBe('0') + expect(denied.headers['x-ratelimit-reset']).toMatch(/^\d+$/) + expect(denied.body).toEqual({ + error: 'Too Many Requests', + retryAfterSeconds: expect.any(Number), + }) + expect(checkpoint.checkpointActionAuthority).toHaveBeenCalledTimes(liveCalls) + } finally { + if (originalLimit === undefined) delete admissionConfig.hostRpcAdmissionRlPerMin + else admissionConfig.hostRpcAdmissionRlPerMin = originalLimit + } + }) + + it('legacy adapter validates signed identity and charges one subject bucket without selectors', async () => { + const token = signRpcAccessToken({ + sub: SUBJECT, + typ: 'user', + accessScope: 'team', + teamId: 'team-a', + scopes: ['host:status:read'], + hostRefs: [HOST_REF, `${rpcProxyConfig.hostNamespace}/other`], + jti: 'host-rpc-admission-test-token', + }) + const server = app() + await request(server) + .post( + `/rpc/access/users/${SUBJECT}/mcp-hosts/${encodeURIComponent(HOST_REF)}/host-rpc-admission` + ) + .set('x-rpc-access-token', token) + .expect(204) + await request(server) + .post( + `/rpc/access/users/${SUBJECT}/mcp-hosts/${encodeURIComponent(`${rpcProxyConfig.hostNamespace}/other`)}/host-rpc-admission` + ) + .set('x-rpc-access-token', token) + .expect(204) + expect(admission.checkAndIncrement).toHaveBeenCalledTimes(2) + expect(admission.checkAndIncrement).toHaveBeenNthCalledWith( + 1, + `host-rpc-admission:${SUBJECT}`, + config.hostRpcAdmissionRlPerMin + ) + expect(admission.checkAndIncrement).toHaveBeenNthCalledWith( + 2, + `host-rpc-admission:${SUBJECT}`, + config.hostRpcAdmissionRlPerMin + ) + }) + + it('admits a wake-only signed token without requiring unrelated Host scopes', async () => { + const token = signRpcAccessToken({ + sub: SUBJECT, + typ: 'user', + accessScope: 'team', + teamId: 'team-a', + scopes: ['host:wake:write'], + hostRefs: [HOST_REF], + jti: 'host-rpc-admission-wake-only-token', + }) + await request(app()) + .post( + '/rpc/access/users/' + + SUBJECT + + '/mcp-hosts/' + + encodeURIComponent(HOST_REF) + + '/host-rpc-admission' + ) + .set('x-rpc-access-token', token) + .expect(204) + expect(admission.checkAndIncrement).toHaveBeenCalledOnce() + expect(admission.checkAndIncrement).toHaveBeenCalledWith( + 'host-rpc-admission:' + SUBJECT, + config.hostRpcAdmissionRlPerMin + ) + }) + + it('invalid legacy scope, subject/Host claim, and body consume no admission unit', async () => { + const token = signRpcAccessToken({ + sub: SUBJECT, + typ: 'user', + accessScope: 'team', + teamId: 'team-a', + scopes: ['host:status:read'], + hostRefs: [HOST_REF], + jti: 'host-rpc-admission-negative-test-token', + }) + const server = app() + await request(server) + .post( + `/rpc/access/users/${SUBJECT}/mcp-hosts/${encodeURIComponent(HOST_REF)}/host-rpc-admission` + ) + .set( + 'x-rpc-access-token', + signRpcAccessToken({ + sub: SUBJECT, + typ: 'user', + accessScope: 'team', + teamId: 'team-a', + scopes: ['mcp:servers:list'], + hostRefs: [HOST_REF], + jti: 'host-rpc-admission-wrong-scope-token', + }) + ) + .expect(403) + await request(server) + .post( + `/rpc/access/users/10000000-0000-4000-8000-000000000009/mcp-hosts/${encodeURIComponent(HOST_REF)}/host-rpc-admission` + ) + .set('x-rpc-access-token', token) + .expect(403) + await request(server) + .post(`/rpc/access/users/${SUBJECT}/mcp-hosts/other/host-rpc-admission`) + .set('x-rpc-access-token', token) + .expect(403) + await request(server) + .post( + `/rpc/access/users/${SUBJECT}/mcp-hosts/${encodeURIComponent(HOST_REF)}/host-rpc-admission` + ) + .set('x-rpc-access-token', token) + .send({ operation: 'client-selected' }) + .expect(400) + expect(admission.checkAndIncrement).not.toHaveBeenCalled() + }) +}) diff --git a/control-api/test/routes.hostMessageV2AdmissionReceipt.test.ts b/control-api/test/routes.hostMessageV2AdmissionReceipt.test.ts index 6fc32c64ff..11c33839e2 100644 --- a/control-api/test/routes.hostMessageV2AdmissionReceipt.test.ts +++ b/control-api/test/routes.hostMessageV2AdmissionReceipt.test.ts @@ -17,7 +17,10 @@ const directory = vi.hoisted(() => ({ getUserContexts: vi.fn(), })) -vi.mock('../src/services/rateLimiterService.js', () => admission) +vi.mock('../src/services/rateLimiterService.js', () => ({ + ...admission, + checkAndIncrementStrict: admission.checkAndIncrement, +})) vi.mock('../src/services/directory/index.js', () => directory) vi.mock('../src/services/access/actionAuthorityCheckpoint.js', async importOriginal => ({ ...(await importOriginal()), @@ -52,7 +55,7 @@ const resource = canonicalResourceIdentity({ }) function checkpointRequest( - operationId: 'chat.message.invoke' | 'host.status.read', + operationId: 'chat.message.invoke' | 'host.status.read' | 'remote_desktop.status', index: number, receipt?: string, nonceIndex = index, @@ -444,7 +447,7 @@ describe('Spec 62 v2 Host-message admission and retry receipt', () => { it('does not charge unrelated action-authority checkpoint operations', async () => { await request(app()) .post('/internal/action-authority/checkpoint') - .send(checkpointRequest('host.status.read', 1)) + .send(checkpointRequest('remote_desktop.status', 1)) .expect(200) expect(admission.checkAndIncrement).not.toHaveBeenCalled() }) diff --git a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts index ef3ea6b16d..51c82279ac 100644 --- a/control-api/test/services.rateLimiter.realPostgres.integration.test.ts +++ b/control-api/test/services.rateLimiter.realPostgres.integration.test.ts @@ -1,13 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { randomBytes } from 'node:crypto' import { Pool } from 'pg' +import { config } from '../src/config.js' import { initDb } from '../src/db.js' import { admitHostMessage, hostMessageAdmissionBucketKey, } from '../src/services/hostMessageAdmission.js' +import { admitHostRpc, hostRpcAdmissionBucketKey } from '../src/services/hostRpcAdmission.js' import { acquireRateLimitConcurrencyLease, + checkAndIncrementStrictWithQuery, checkAndIncrementWithQuery, } from '../src/services/rateLimiterService.js' @@ -35,6 +38,10 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { const sandboxOtherUserKey = `sandbox-oauth-token-vend:${sandboxUserB}` const messageSubject = `user-${randomBytes(8).toString('hex')}` const messageAdmissionKey = hostMessageAdmissionBucketKey(messageSubject) + const hostRpcSubject = `user-${randomBytes(8).toString('hex')}` + const hostRpcOtherSubject = `user-${randomBytes(8).toString('hex')}` + const hostRpcKey = hostRpcAdmissionBucketKey(hostRpcSubject) + const hostRpcOtherKey = hostRpcAdmissionBucketKey(hostRpcOtherSubject) const connectionString = databaseUrl( adminUrl ?? 'postgresql://postgres@127.0.0.1/postgres', database @@ -59,6 +66,8 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { sandboxDisconnectKey, sandboxOtherUserKey, messageAdmissionKey, + hostRpcKey, + hostRpcOtherKey, ], ]) await pool?.end() @@ -216,6 +225,65 @@ describeRealPostgres('rate limiter atomicity on real PostgreSQL', () => { } }) + it('enforces one strict Host-RPC subject budget across sessions and fixed windows', async () => { + const clients = [await pool.connect(), await pool.connect()] + const mutableConfig = config as typeof config & { hostRpcAdmissionRlPerMin: number } + const originalLimit = mutableConfig.hostRpcAdmissionRlPerMin + mutableConfig.hostRpcAdmissionRlPerMin = 3 + const admitOn = (client: (typeof clients)[number], at = windowStartMs) => + admitHostRpc(hostRpcSubject, (key, limit) => + checkAndIncrementStrictWithQuery( + (text, values) => client.query(text, values), + key, + limit, + at, + 1 + ) + ) + try { + const results = [] + for (let index = 0; index < 4; index += 1) { + results.push(await admitOn(clients[index % clients.length])) + } + expect(results.map(result => result.status)).toEqual([ + 'allowed', + 'allowed', + 'allowed', + 'limited', + ]) + expect(results.filter(result => result.status === 'allowed')).toHaveLength(3) + expect(results.filter(result => result.status === 'limited')).toHaveLength(1) + + const persisted = await pool.query<{ count: string }>( + 'SELECT count FROM rate_limit_buckets WHERE bucket_key = $1 AND window_start_ms = $2', + [hostRpcKey, windowStartMs] + ) + expect(Number(persisted.rows[0]?.count)).toBe(4) + + const otherSubject = await admitHostRpc(hostRpcOtherSubject, (key, limit) => + checkAndIncrementStrictWithQuery( + (text, values) => clients[1].query(text, values), + key, + limit, + windowStartMs, + 1 + ) + ) + expect(otherSubject.status).toBe('allowed') + + const nextWindow = await admitOn(clients[0], windowStartMs + 60_000) + expect(nextWindow).toMatchObject({ status: 'allowed', check: { count: 1 } }) + + const failedStore = await admitHostRpc(hostRpcSubject, async () => { + throw new Error('store unavailable') + }) + expect(failedStore).toEqual({ status: 'unavailable' }) + } finally { + mutableConfig.hostRpcAdmissionRlPerMin = originalLimit + clients.forEach(client => client.release()) + } + }) + it('serializes the production bounded advisory lease across independent PostgreSQL sessions', async () => { const key = `gfs-upload-slot:${randomBytes(8).toString('hex')}` const first = await pool.connect() diff --git a/control-api/test/services.rateLimiter.test.ts b/control-api/test/services.rateLimiter.test.ts index 522a9c6d44..5aa1a1a705 100644 --- a/control-api/test/services.rateLimiter.test.ts +++ b/control-api/test/services.rateLimiter.test.ts @@ -5,6 +5,8 @@ import { rateLimitBackendErrorsTotal } from '../src/observability/metrics.js' import { acquireRateLimitConcurrencyLease, checkAndIncrement, + checkAndIncrementStrict, + checkAndIncrementStrictWithQuery, cleanupExpiredBuckets, currentWindowStartMs, startRateLimiterCleanup, @@ -168,6 +170,13 @@ describe('rateLimiterService', () => { ]) }) + it('rejects an invalid cost before entering the generic fail-open boundary', async () => { + await expect(checkAndIncrement('test:bucket:invalid-cost', 5, Date.now(), 0)).rejects.toThrow( + 'rate limit cost must be positive' + ) + expect(mockRateLimitPoolQuery).not.toHaveBeenCalled() + }) + it('holds replica-safe advisory slots until release and then admits the next request', async () => { const requirements = [{ bucketKey: 'gfs-upload:test-active', maxConcurrent: 2 }] const [first, second, denied] = await Promise.all([ @@ -279,6 +288,48 @@ describe('rateLimiterService', () => { expect(r.backendAvailable).toBe(false) }) + it('strict admission rejects DB errors and missing rows without changing generic fail-open', async () => { + mockRateLimitPoolQuery.mockRejectedValueOnce(new Error('connection refused')) + await expect(checkAndIncrementStrict('strict:failed', 5)).rejects.toThrow('connection refused') + + mockRateLimitPoolQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + await expect(checkAndIncrementStrict('strict:missing', 5)).rejects.toThrow( + 'rate limit store returned no admission state' + ) + + await expect( + checkAndIncrementStrictWithQuery(async () => ({ rows: [] }), 'strict:missing-query', 5) + ).rejects.toThrow('rate limit store returned no admission state') + }) + + it('strict backend failures are measured and logged without bucket or error-message leakage', async () => { + const bucketKey = 'host-rpc-admission:private-subject-sentinel' + const privateMessage = 'private-db-message-sentinel' + const warn = vi.spyOn(rootLogger, 'warn').mockImplementation(() => undefined) + const before = await errorCount() + const failure = Object.assign(new Error(privateMessage), { code: '08006' }) + mockRateLimitPoolQuery.mockRejectedValueOnce(failure) + + try { + await expect(checkAndIncrementStrict(bucketKey, 5)).rejects.toBe(failure) + + expect((await errorCount()) - before).toBe(1) + expect(warn).toHaveBeenCalledTimes(1) + const fields = warn.mock.calls[0]?.[0] + expect(fields).toMatchObject({ + event: 'rate_limit_db_error', + errorName: 'Error', + errorCode: '08006', + suppressed: 0, + }) + expect(fields).not.toHaveProperty('err') + expect(JSON.stringify(fields)).not.toContain(bucketKey) + expect(JSON.stringify(fields)).not.toContain(privateMessage) + } finally { + warn.mockRestore() + } + }) + it('cleanupExpiredBuckets removes rows older than 5 minutes', async () => { const now = 1_700_000_000_000 // Seed two buckets: one old (>5 min ago), one fresh. diff --git a/control-api/test/services.rpcHostAccessAuthorizer.test.ts b/control-api/test/services.rpcHostAccessAuthorizer.test.ts index 64abbc801f..8f04c1723f 100644 --- a/control-api/test/services.rpcHostAccessAuthorizer.test.ts +++ b/control-api/test/services.rpcHostAccessAuthorizer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { K8sGateway } from '../src/k8s.js' import type { RpcAccessClaims } from '../src/profileTypes.js' import { authorizeRpcHostAccess } from '../src/services/access/rpcHostAccessAuthorizer.js' +import { signRpcAccessToken, verifyRpcAccessToken } from '../src/utils/auth/rpcAuthToken.js' const CLAIMS: RpcAccessClaims = { sub: 'user-1', @@ -48,6 +49,81 @@ function dependencies( } describe('authorizeRpcHostAccess', () => { + function issuedClaims(hostRefs: string[]): RpcAccessClaims { + const token = signRpcAccessToken({ + sub: CLAIMS.sub, + typ: CLAIMS.typ, + teamId: CLAIMS.teamId, + role: CLAIMS.role, + scopes: CLAIMS.scopes, + hostRefs, + jti: `issued-${hostRefs.join('-') || 'empty'}`, + }) + const verified = verifyRpcAccessToken(token) + if (!verified) throw new Error('Control API rejected its own signed RPC token') + return verified + } + + it('intersects signed Host refs with live direct grants across grants and revocation', async () => { + const hosts = [ + { metadata: { name: 'host-a' }, spec: { enabled: true } }, + { metadata: { name: 'host-b' }, spec: { enabled: true } }, + ] + const tokenA = issuedClaims(['host-a']) + const liveBoth = dependencies({ userAgents: ['host-a', 'host-b'], hosts }) + await expect( + authorizeRpcHostAccess(liveBoth.gateway, tokenA, 'user-1', 'host-a', liveBoth.directory) + ).resolves.toMatchObject({ authorized: true }) + await expect( + authorizeRpcHostAccess(liveBoth.gateway, tokenA, 'user-1', 'host-b', liveBoth.directory) + ).resolves.toEqual({ authorized: false, reason: 'host_claim_missing' }) + + const reminted = issuedClaims(['host-a', 'host-b']) + await expect( + authorizeRpcHostAccess(liveBoth.gateway, reminted, 'user-1', 'host-b', liveBoth.directory) + ).resolves.toMatchObject({ authorized: true }) + const revokedB = dependencies({ userAgents: ['host-a'], hosts }) + await expect( + authorizeRpcHostAccess(revokedB.gateway, reminted, 'user-1', 'host-b', revokedB.directory) + ).resolves.toEqual({ authorized: false, reason: 'directory_grant_missing' }) + await expect( + authorizeRpcHostAccess(revokedB.gateway, reminted, 'user-1', 'host-a', revokedB.directory) + ).resolves.toMatchObject({ authorized: true }) + }) + + it('applies the signed ceiling to team grants and rejects subject substitution', async () => { + const claims = issuedClaims(['host-a', 'host-b']) + const liveTeam = dependencies({ + userAgents: [], + teamAgents: ['host-b'], + hosts: [{ metadata: { name: 'host-b' }, spec: { enabled: true } }], + }) + await expect( + authorizeRpcHostAccess(liveTeam.gateway, claims, 'user-1', 'host-b', liveTeam.directory) + ).resolves.toMatchObject({ authorized: true }) + await expect( + authorizeRpcHostAccess( + liveTeam.gateway, + claims, + 'different-user', + 'host-b', + liveTeam.directory + ) + ).resolves.toEqual({ authorized: false, reason: 'subject_mismatch' }) + expect( + verifyRpcAccessToken( + signRpcAccessToken({ + sub: CLAIMS.sub, + typ: CLAIMS.typ, + teamId: CLAIMS.teamId, + role: CLAIMS.role, + scopes: CLAIMS.scopes, + hostRefs: [], + jti: 'issued-empty', + }) + ) + ).toBeNull() + }) it('returns a typed subject mismatch before directory or Kubernetes access', async () => { const { gateway, directory } = dependencies() await expect( diff --git a/deploy/base/control-plane/configmaps.yaml b/deploy/base/control-plane/configmaps.yaml index af4d0aa6e5..c76ec77529 100644 --- a/deploy/base/control-plane/configmaps.yaml +++ b/deploy/base/control-plane/configmaps.yaml @@ -224,6 +224,22 @@ data: proxy_set_header X-Forwarded-Proto $scheme; } + # Spec 65: only RPC Proxy may charge the subject-wide Host-RPC bucket. + # Keep this exact POST endpoint separate from generic internal routes. + location ~ ^/api/v1/rpc/access/users/[^/]+/mcp-hosts/[^/]+/host-rpc-admission$ { + limit_except POST { + deny all; + } + proxy_pass http://control_api_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_set_header X-Service-Token $http_x_service_token; + proxy_set_header X-Rpc-Access-Token $http_x_rpc_access_token; + } + # Stateless Agents — wake trigger for a suspended/draining host. # rpc-proxy forwards the caller's rpc access token; control-api # authorizes via requireRpcTokenHostMatch before any mutation. diff --git a/desktop-app/src/__tests__/appService.progress.test.ts b/desktop-app/src/__tests__/appService.progress.test.ts index 61476d6dd1..3d9e4f2ad7 100644 --- a/desktop-app/src/__tests__/appService.progress.test.ts +++ b/desktop-app/src/__tests__/appService.progress.test.ts @@ -244,6 +244,31 @@ describe('AppService.startTaskProgressStream', () => { } }) + it('does not reconnect a stream before the server Retry-After after admission 429', async () => { + vi.useFakeTimers() + try { + const service = makeService() + let calls = 0 + service.rpcClient = { + openTaskProgressStream: vi.fn().mockImplementation(async () => { + calls += 1 + throw new ApiError('Too Many Requests', 429, '', '20') + }), + } + service.startTaskProgressStream('s429', 7, 'chatllm', 'task-1', ['chatllm'], () => {}) + + await flushAsyncWork() + expect(calls).toBe(1) + await vi.advanceTimersByTimeAsync(19_999) + expect(calls).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(calls).toBe(2) + service.stopTaskProgressStream('s429') + } finally { + vi.useRealTimers() + } + }) + it('does not retry on task_not_found_or_expired; surfaces the loss for reconcile', async () => { vi.useFakeTimers() try { diff --git a/desktop-app/src/__tests__/rpcProxyClient.test.ts b/desktop-app/src/__tests__/rpcProxyClient.test.ts index 7d0557a43c..6bec508741 100644 --- a/desktop-app/src/__tests__/rpcProxyClient.test.ts +++ b/desktop-app/src/__tests__/rpcProxyClient.test.ts @@ -27,6 +27,29 @@ describe('RpcProxyClient.invokeHostMessage admission', () => { }) }) +describe('RpcProxyClient Host stream admission', () => { + it('preserves 429 status and Retry-After as ApiError metadata', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{"error":"Too Many Requests"}', { + status: 429, + headers: { 'Retry-After': '23' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + try { + const client = new RpcProxyClient() + const rejection = await client + .openHostStatusStream('rpc-token', 'host-a', () => {}, new AbortController().signal) + .catch(error => error) + expect(rejection).toBeInstanceOf(ApiError) + expect(rejection).toMatchObject({ status: 429, retryAfter: '23' }) + expect(fetchMock).toHaveBeenCalledOnce() + } finally { + vi.unstubAllGlobals() + } + }) +}) + // Mock the config module so the module-level url() helper uses a fixed base URL vi.mock('../config.js', () => ({ config: { @@ -46,6 +69,26 @@ describe('RpcProxyClient.listSessions', () => { vi.restoreAllMocks() }) + it('surfaces W1 400 instead of treating it as absent session or available Host', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => 'Invalid hostRef', + }) + ) + await expect(client.listSessions('token', 'invalid_host')).rejects.toMatchObject({ + status: 400, + }) + await expect( + client.loadSessionMessages('token', 'invalid_host', 'agent', 'chat') + ).rejects.toMatchObject({ status: 400 }) + await expect( + client.getContextBreakdown('token', 'invalid_host', 'agent', 'chat') + ).rejects.toMatchObject({ status: 400 }) + }) + it.each(['.', '..', 'host/name', 'host\\name', 'host\nforged', 'x'.repeat(501)])( 'rejects an unsafe host path before issuing a request: %s', async hostRef => { @@ -851,6 +894,16 @@ describe('RpcProxyClient.getHostModels', () => { await expect(client.getHostModels('t', 'h', 'c')).resolves.toBeNull() }) + it('does not treat W1 Host-ref 400 as an unavailable model-list feature', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 400, text: async () => 'Invalid hostRef' }) + ) + await expect(client.getHostModels('t', 'invalid_host', 'c')).rejects.toMatchObject({ + status: 400, + }) + }) + it('throws on a genuine server error (500)', async () => { vi.stubGlobal( 'fetch', diff --git a/desktop-app/src/appService.ts b/desktop-app/src/appService.ts index c64b322e03..dfd5841343 100644 --- a/desktop-app/src/appService.ts +++ b/desktop-app/src/appService.ts @@ -256,6 +256,13 @@ const PREWARM_COOLDOWN_MAX_HOSTS = 256 const PREWARM_REEMIT_MAX_ATTEMPTS = 2 const PREWARM_REEMIT_INTERVAL_MS = 10_000 +function retryAfterDelayMs(error: unknown): number { + if (!(error instanceof ApiError) || error.status !== 429 || !error.retryAfter) return 0 + if (!/^\d+$/.test(error.retryAfter.trim())) return 0 + const seconds = Number(error.retryAfter) + return Number.isSafeInteger(seconds) && seconds > 0 ? seconds * 1000 : 0 +} + function compareSemverLike(left: string, right: string): number { const parse = (value: string) => value @@ -3626,6 +3633,7 @@ export class AppService { const connect = async () => { if (closed) return abortController = new AbortController() + let serverRetryAfterMs = 0 try { const rpc = await this.issueRpcTokenForHostRefs(HOST_STATUS_SCOPES, effectiveHostRefs) await this.rpcClient.openHostStatusStream( @@ -3675,6 +3683,7 @@ export class AppService { } } catch (error) { if (closed) return + serverRetryAfterMs = retryAfterDelayMs(error) if (error instanceof ApiError && error.status === 401) { this.rpcTokenManager.clear() } @@ -3682,10 +3691,13 @@ export class AppService { } finally { if (closed) return clearRetry() - retryTimer = setTimeout(() => { - backoffMs = Math.min(backoffMs * 2, 15000) - void connect() - }, backoffMs) + retryTimer = setTimeout( + () => { + backoffMs = Math.min(backoffMs * 2, 15000) + void connect() + }, + Math.max(backoffMs, serverRetryAfterMs) + ) } } @@ -3752,6 +3764,7 @@ export class AppService { const connect = async () => { if (closed) return abortController = new AbortController() + let serverRetryAfterMs = 0 try { const rpc = await this.issueRpcTokenForHostRefs(HOST_ACTIVITY_SCOPES, effectiveHostRefs) await this.rpcClient.openHostActivityStream( @@ -3800,6 +3813,7 @@ export class AppService { if (!closed) onEvent({ type: 'error', message: 'Activity stream disconnected' }) } catch (error) { if (closed) return + serverRetryAfterMs = retryAfterDelayMs(error) if (error instanceof ApiError && error.status === 401) { this.rpcTokenManager.clear() } @@ -3807,10 +3821,13 @@ export class AppService { } finally { if (closed) return clearRetry() - retryTimer = setTimeout(() => { - backoffMs = Math.min(backoffMs * 2, 15000) - void connect() - }, backoffMs) + retryTimer = setTimeout( + () => { + backoffMs = Math.min(backoffMs * 2, 15000) + void connect() + }, + Math.max(backoffMs, serverRetryAfterMs) + ) } } @@ -4047,6 +4064,7 @@ export class AppService { try { while (!closed && !giveUp) { abortController = new AbortController() + let serverRetryAfterMs = 0 // F3: this 8s bound guards CONNECTION ESTABLISHMENT only (open request → // first server bytes). It's cleared on `waiting` (connection established), // which then arms the longer waiting-for-open bound. A server that never @@ -4062,6 +4080,7 @@ export class AppService { abortController.signal ) } catch (error) { + serverRetryAfterMs = retryAfterDelayMs(error) // F4: a 401 on token re-issue (the 300s RPC token expired mid-stream) // clears the cache so the NEXT attempt re-mints a fresh token, then // falls through to retry. A single transient 401 must NOT collapse a @@ -4083,7 +4102,7 @@ export class AppService { RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1), RECONNECT_MAX_DELAY_MS ) - await new Promise(resolve => setTimeout(resolve, delayMs)) + await new Promise(resolve => setTimeout(resolve, Math.max(delayMs, serverRetryAfterMs))) } // Exhausted/gave up without a clean terminal → surface the loss as a // structured `gone` (§4.5-2), NOT the generic `error`. The renderer's diff --git a/desktop-app/src/rpcProxyClient.ts b/desktop-app/src/rpcProxyClient.ts index 3f7a45e948..d26aa76f40 100644 --- a/desktop-app/src/rpcProxyClient.ts +++ b/desktop-app/src/rpcProxyClient.ts @@ -765,7 +765,12 @@ export class RpcProxyClient { }) if (!response.ok) { const body = await response.text() - throw new Error(`Host stream failed (${response.status}): ${body || response.statusText}`) + throw new ApiError( + `Host stream failed (${response.status}): ${body || response.statusText}`, + response.status, + body, + response.headers.get('retry-after') + ) } if (!response.body) { throw new Error('Host stream missing response body') diff --git a/external-rest-api/src/releaseManifest.ts b/external-rest-api/src/releaseManifest.ts index c9f4bd1815..4362678f2f 100644 --- a/external-rest-api/src/releaseManifest.ts +++ b/external-rest-api/src/releaseManifest.ts @@ -9,7 +9,7 @@ export type ReleaseManifest = { export const releaseManifest: ReleaseManifest = { releaseId: 'v0.10.0', externalRestApiVersion: '0.1.113', - rpcProxyVersion: '0.1.113', + rpcProxyVersion: '0.1.123', desktopVersion: '0.10.0', minimumDesktopVersion: '0.1.252', } diff --git a/mcp-host/package-lock.json b/mcp-host/package-lock.json index 50cd620fac..ec2c3af838 100644 --- a/mcp-host/package-lock.json +++ b/mcp-host/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-mcp-host", - "version": "0.1.477", + "version": "0.1.478", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-mcp-host", - "version": "0.1.477", + "version": "0.1.478", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/sdk": "^0.32.0", diff --git a/mcp-host/package.json b/mcp-host/package.json index e1f3148881..589b19b3d7 100644 --- a/mcp-host/package.json +++ b/mcp-host/package.json @@ -1,6 +1,6 @@ { "name": "clerum-mcp-host", - "version": "0.1.477", + "version": "0.1.478", "description": "MCP Host - reads Host CRD and provides LLM access via OpenAI/Claude", "main": "dist/main.js", "engines": { diff --git a/mcp-host/src/__tests__/server.interface.contract.test.ts b/mcp-host/src/__tests__/server.interface.contract.test.ts index eb33e2aa8b..29b12700ef 100644 --- a/mcp-host/src/__tests__/server.interface.contract.test.ts +++ b/mcp-host/src/__tests__/server.interface.contract.test.ts @@ -354,6 +354,19 @@ describe('RPCServer v1 runtime interface contract', () => { }) expect(ok.status).toBe(200) + const missingRequestId = await fetch(`${baseUrl}/v1/runtime/approvals/deny`, { + method: 'POST', + headers: { + ...rpcEdgeHeaders('edge-user-1'), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ taskId: 'task-1' }), + }) + expect(missingRequestId.status).toBe(400) + expect(await missingRequestId.json()).toEqual({ + error: 'Missing userId or requestId', + }) + const missingUser = await fetch(`${baseUrl}/v1/runtime/approvals/deny`, { method: 'POST', headers: { diff --git a/mcp-host/src/agent/sessionTitle.ts b/mcp-host/src/agent/sessionTitle.ts index 778875c33a..623a3150bb 100644 --- a/mcp-host/src/agent/sessionTitle.ts +++ b/mcp-host/src/agent/sessionTitle.ts @@ -17,12 +17,8 @@ * (→ 404). No secret handling here; the title is user content, logged only by * length (§5). */ +import { validateSessionRenameTitle } from '@clerum/action-context-contracts' import type { ConversationManager } from '../core/conversation/conversation' -import { - MAX_TITLE_BYTES, - MAX_TITLE_CODE_POINTS, - normalizeTitleText, -} from '../core/conversation/sessionTitle' import type { SetTitleResult } from '../server/types' import { serializeSessionKey } from '../session' @@ -43,14 +39,11 @@ export async function applySessionTitle( // rename policy: non-empty after sanitize, capped at 120 code points / 512 // bytes. Reject over-cap (400) rather than silently truncating so the user's // stored name is never a surprise. - const title = normalizeTitleText(rawTitle) - if ( - title.length === 0 || - Array.from(title).length > MAX_TITLE_CODE_POINTS || - Buffer.byteLength(title, 'utf8') > MAX_TITLE_BYTES - ) { + const validation = validateSessionRenameTitle(rawTitle) + if (!validation.ok) { return { ok: false as const, reason: 'invalid_title' as const } } + const title = validation.title const key = serializeSessionKey({ userId: userSub, diff --git a/mcp-host/src/core/conversation/sessionTitle.ts b/mcp-host/src/core/conversation/sessionTitle.ts index cacdb89354..924ec2f33e 100644 --- a/mcp-host/src/core/conversation/sessionTitle.ts +++ b/mcp-host/src/core/conversation/sessionTitle.ts @@ -1,3 +1,9 @@ +import { + MAX_SESSION_TITLE_BYTES, + MAX_SESSION_TITLE_CODE_POINTS, + normalizeSessionTitle, +} from '@clerum/action-context-contracts' + /** * Pure derivation of the server-authoritative auto-title for a session's first * turn (spec 15, Fase A). The title is materialized once, on turn 1, into @@ -31,8 +37,8 @@ export const AUTO_TITLE_MAX_CODE_POINTS = 60 /** Rename caps (spec 15 §5). Code-point cap for BMP text; byte cap binds first * for astral-heavy titles (up to 4 bytes/code point). */ -export const MAX_TITLE_CODE_POINTS = 120 -export const MAX_TITLE_BYTES = 512 +export const MAX_TITLE_CODE_POINTS = MAX_SESSION_TITLE_CODE_POINTS +export const MAX_TITLE_BYTES = MAX_SESSION_TITLE_BYTES /** * Shared text-sanitization core for BOTH the auto-title derivation and the @@ -46,25 +52,7 @@ export const MAX_TITLE_BYTES = 512 * glue two words together. A second collapse removes a double space left when a * stripped format char sat between two spaces. */ -export function normalizeTitleText(input: string): string { - return ( - input - .normalize('NFC') - // Fold every whitespace variant (newline, tab, NBSP, line/para separators). - .replace(/\s+/g, ' ') - // Strip bidi overrides, zero-width, and any other invisible control/format - // code point (`\p{C}`) that `\s` does not cover. Spaces are `\p{Zs}`, not - // `\p{C}`, so word boundaries survive; valid astral chars (emoji) are their - // own category, so only lone surrogates are dropped. - // Tradeoff: U+200D (ZWJ) is `\p{C}` too, so a ZWJ emoji sequence - // (e.g. 👨‍💻) splits into its base emoji. Accepted: a title gates a - // destructive rename dialog, so anti-spoofing (no hidden joiners) wins over - // emoji fidelity — same call the read-side sanitizer makes. - .replace(/\p{C}/gu, '') - .replace(/\s+/g, ' ') - .trim() - ) -} +export const normalizeTitleText = normalizeSessionTitle /** * Derive a short session title from a (already-redacted) user input. diff --git a/mcp-host/src/server/routes.ts b/mcp-host/src/server/routes.ts index f9cfefee49..8b24744707 100644 --- a/mcp-host/src/server/routes.ts +++ b/mcp-host/src/server/routes.ts @@ -1,4 +1,9 @@ import type { Request, Response } from 'express' +import { + validateHostActivityLimit, + validateHostApprovalRequestId, + validateHostModelSelectionRequest, +} from '@clerum/action-context-contracts' import { parseIncomingFileReferences } from '../agent/fileReferenceResolver' import { config } from '../config' import { ConversationError, ConversationErrorCode } from '../core/errors' @@ -43,12 +48,6 @@ import type { import type { RuntimeCallerContext } from './types' import { decodeSessionsCursor, sessionsCursorScope } from './wireProjections' -/** Narrows an untrusted body field to the CAS revision the model routes accept, - * so the call site needs no cast to drop `unknown`. */ -function isNonNegativeSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 -} - export type RouteHandlers = { directServiceAdmission?: () => { allowed: true } | { allowed: false; retryAfterSeconds: number } afterDirectMessageAdmission?: (req: Request, res: Response) => boolean @@ -519,12 +518,12 @@ export async function handleActivityRoute( json(res, 501, { hostRef: 'unknown', version: '1.0', items: [], nextCursor: null }) return } - const limitRaw = String(req.query.limit || '50') - const limit = Number(limitRaw) - if (!Number.isFinite(limit) || limit <= 0) { - badRequest(res, 'limit must be a positive number') + const parsedLimit = validateHostActivityLimit(req.query.limit) + if (!parsedLimit.ok) { + badRequest(res, parsedLimit.error) return } + const limit = parsedLimit.limit const sinceEventId = typeof req.query.sinceEventId === 'string' ? req.query.sinceEventId : undefined const snapshot = await handlers.activitySnapshotHandler( @@ -660,17 +659,19 @@ export async function handleApprovalRoute( const userId = caller?.caller === 'rpc-proxy' ? caller.userId : (parsed.userId as string | undefined) const requestId = parsed.requestId as string | undefined - if (!userId || !requestId) { + const requestIdValidation = validateHostApprovalRequestId(requestId) + if (!userId || !requestIdValidation.ok) { badRequest(res, 'Missing userId or requestId') return } + const validatedRequestId = requestIdValidation.requestId as string if ( rejectV2TargetMismatch(req, res, { hostRef: caller?.actionContextV2?.target?.hostRef, taskId: typeof parsed.taskId === 'string' ? parsed.taskId.trim() : undefined, action: approved ? 'approve' : 'deny', approvalRequestId: - typeof parsed.toolCallId === 'string' ? parsed.toolCallId.trim() : requestId, + typeof parsed.toolCallId === 'string' ? parsed.toolCallId.trim() : validatedRequestId, }) ) { return @@ -698,7 +699,7 @@ export async function handleApprovalRoute( const decision: ApprovalDecision = { userId, - requestId, + requestId: validatedRequestId, approved, alwaysApprove: approved ? (parsed.alwaysApprove as boolean) || false : false, channelType, @@ -706,7 +707,7 @@ export async function handleApprovalRoute( } logger.info( - { detail: approved ? 'Approval' : 'Denial', userId: userId, requestId: requestId }, + { detail: approved ? 'Approval' : 'Denial', userId, requestId: validatedRequestId }, '[Server] from for request' ) const result = await handlers.approvalHandler(decision) @@ -1669,23 +1670,14 @@ export async function handleSetModelRoute( return } const body = (req.body as Record) || {} - const chatId = typeof body.chatId === 'string' ? body.chatId.trim() : '' - const model = typeof body.model === 'string' ? body.model.trim() : '' - const agent = typeof body.agent === 'string' ? body.agent.trim() : '' - const provider = typeof body.provider === 'string' ? body.provider.trim() : '' - if (!chatId) { - badRequest(res, 'chatId is required') - return - } - if (!model) { - badRequest(res, 'model is required') - return - } - const expectedRevision = body.expectedRevision - if (expectedRevision !== undefined && !isNonNegativeSafeInteger(expectedRevision)) { - badRequest(res, 'expectedRevision must be a non-negative integer') + const modelRequest = validateHostModelSelectionRequest(body) + if (!modelRequest.ok) { + badRequest(res, modelRequest.error) return } + const { chatId, model, expectedRevision } = modelRequest + const agent = typeof body.agent === 'string' ? body.agent.trim() : '' + const provider = typeof body.provider === 'string' ? body.provider.trim() : '' if ( rejectV2TargetMismatch(req, res, { hostRef: caller.actionContextV2?.target?.hostRef, diff --git a/mcp-host/src/server/wireProjections.ts b/mcp-host/src/server/wireProjections.ts index 118d672e93..b5c18f0513 100644 --- a/mcp-host/src/server/wireProjections.ts +++ b/mcp-host/src/server/wireProjections.ts @@ -1,4 +1,8 @@ -import { createHash } from 'node:crypto' +import { + decodeSessionsCursor, + encodeSessionsCursor, + sessionsCursorScope, +} from '@clerum/action-context-contracts' import type { Conversation, Turn } from '../core/types' import { getDisplayName } from '../progress/intentExtraction' import type { ContextBreakdownWire, SessionTokensWire, TurnToolStepWire } from './types' @@ -16,65 +20,7 @@ type SessionTokenSource = Pick< | 'cacheTokensReported' > -export interface SessionsCursor { - version: 1 - scope: string - updatedAt: string - key: string -} - -function isCanonicalIsoTimestamp(value: string): boolean { - const milliseconds = Date.parse(value) - return Number.isFinite(milliseconds) && new Date(milliseconds).toISOString() === value -} - -export function sessionsCursorScope(userSub: string, agent?: string): string { - return createHash('sha256') - .update(JSON.stringify([userSub, agent ?? null])) - .digest('base64url') - .slice(0, 24) -} - -export function decodeSessionsCursor( - cursor: string | undefined, - expectedScope?: string -): SessionsCursor | null { - if (!cursor) return null - try { - const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { - version?: unknown - scope?: unknown - updatedAt?: unknown - key?: unknown - } - if ( - parsed.version !== 1 || - typeof parsed.scope !== 'string' || - parsed.scope.length === 0 || - (expectedScope !== undefined && parsed.scope !== expectedScope) || - typeof parsed.updatedAt !== 'string' || - typeof parsed.key !== 'string' || - parsed.key.length === 0 || - !isCanonicalIsoTimestamp(parsed.updatedAt) - ) { - return null - } - return { - version: 1, - scope: parsed.scope, - updatedAt: parsed.updatedAt, - key: parsed.key, - } - } catch { - return null - } -} - -export function encodeSessionsCursor(updatedAt: string, key: string, scope = 'unscoped'): string { - return Buffer.from(JSON.stringify({ version: 1, scope, updatedAt, key }), 'utf8').toString( - 'base64url' - ) -} +export { decodeSessionsCursor, encodeSessionsCursor, sessionsCursorScope } export function paginateSessionSummaries( entries: T[], diff --git a/packages/action-context-contracts/index.cjs b/packages/action-context-contracts/index.cjs index fa399b5fa3..85bb6379ab 100644 --- a/packages/action-context-contracts/index.cjs +++ b/packages/action-context-contracts/index.cjs @@ -43,6 +43,113 @@ const AUTHORIZATION_REVISION_PATTERN = /^ar1_[A-Za-z0-9_-]{43}$/ const ACCESS_PATH_ID_PATTERN = /^ap1_[A-Za-z0-9_-]{43}$/ const BEHAVIOR_BINDING_HASH_PATTERN = /^bh2_[A-Za-z0-9_-]{43}$/ +// Host-route deterministic request validation shared by RPC Proxy admission +// preflight and MCP Host's independently enforced runtime boundary. +const MAX_SESSION_TITLE_CODE_POINTS = 120 +const MAX_SESSION_TITLE_BYTES = 512 + +function normalizeSessionTitle(input) { + return input + .normalize('NFC') + .replace(/\s+/g, ' ') + .replace(/\p{C}/gu, '') + .replace(/\s+/g, ' ') + .trim() +} + +function validateSessionRenameTitle(value) { + const rawTitle = typeof value === 'string' ? value : '' + const title = normalizeSessionTitle(rawTitle) + const exceedsCodePointLimit = + title.length > MAX_SESSION_TITLE_CODE_POINTS * 2 || + Array.from(title).length > MAX_SESSION_TITLE_CODE_POINTS + if ( + title.length === 0 || + exceedsCodePointLimit || + Buffer.byteLength(title, 'utf8') > MAX_SESSION_TITLE_BYTES + ) { + return Object.freeze({ ok: false, error: 'invalid title' }) + } + return Object.freeze({ ok: true, title }) +} + +function validateHostModelSelectionRequest(value) { + const body = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + const chatId = typeof body.chatId === 'string' ? body.chatId.trim() : '' + const model = typeof body.model === 'string' ? body.model.trim() : '' + if (!chatId) return Object.freeze({ ok: false, error: 'chatId is required' }) + if (!model) return Object.freeze({ ok: false, error: 'model is required' }) + if ( + body.expectedRevision !== undefined && + (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) + ) { + return Object.freeze({ ok: false, error: 'expectedRevision must be a non-negative integer' }) + } + return Object.freeze({ + ok: true, + chatId, + model, + ...(body.expectedRevision === undefined ? {} : { expectedRevision: body.expectedRevision }), + }) +} + +function validateHostApprovalRequestId(value) { + if (!value) { + return Object.freeze({ ok: false, error: 'Missing userId or requestId' }) + } + return Object.freeze({ ok: true, requestId: value }) +} + +function sessionsCursorScope(userSub, agent) { + return createHash('sha256') + .update(JSON.stringify([userSub, agent ?? null])) + .digest('base64url') + .slice(0, 24) +} + +function decodeSessionsCursor(cursor, expectedScope) { + if (!cursor) return null + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + const milliseconds = typeof parsed.updatedAt === 'string' ? Date.parse(parsed.updatedAt) : NaN + if ( + parsed.version !== 1 || + typeof parsed.scope !== 'string' || + parsed.scope.length === 0 || + (expectedScope !== undefined && parsed.scope !== expectedScope) || + typeof parsed.updatedAt !== 'string' || + !Number.isFinite(milliseconds) || + new Date(milliseconds).toISOString() !== parsed.updatedAt || + typeof parsed.key !== 'string' || + parsed.key.length === 0 + ) { + return null + } + return Object.freeze({ + version: 1, + scope: parsed.scope, + updatedAt: parsed.updatedAt, + key: parsed.key, + }) + } catch { + return null + } +} + +function encodeSessionsCursor(updatedAt, key, scope = 'unscoped') { + return Buffer.from(JSON.stringify({ version: 1, scope, updatedAt, key }), 'utf8').toString( + 'base64url' + ) +} + +function validateHostActivityLimit(value) { + const limit = Number(value || '50') + if (!Number.isFinite(limit) || limit <= 0) { + return Object.freeze({ ok: false, error: 'limit must be a positive number' }) + } + return Object.freeze({ ok: true, limit }) +} + const ACCESS_RESOURCE_TYPES = Object.freeze([ 'user', 'team', @@ -567,6 +674,30 @@ function validateHostMessageAdmissionFailureResponse(value) { throw new ActionAuthorityCheckpointWireError() } +function validateHostRpcAdmissionFailureResponse(value) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + value.error === 'Too Many Requests' && + hasExactKeys(value, ['error', 'retryAfterSeconds']) && + Number.isSafeInteger(value.retryAfterSeconds) && + value.retryAfterSeconds > 0 + ) { + return Object.freeze({ error: 'Too Many Requests', retryAfterSeconds: value.retryAfterSeconds }) + } + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + value.error === 'host_rpc_admission_unavailable' && + hasExactKeys(value, ['error']) + ) { + return Object.freeze({ error: 'host_rpc_admission_unavailable' }) + } + throw new ActionAuthorityCheckpointWireError() +} + function boundedResourceText(value, code, maximum) { const normalized = typeof value === 'string' ? value.trim() : '' if (!normalized || normalized.length > maximum || CONTROL_CHARACTER_PATTERN.test(normalized)) { @@ -1121,6 +1252,17 @@ module.exports = { validateActionAuthorityCheckpointResponse, validateHostMessageAdmissionContext, validateHostMessageAdmissionFailureResponse, + validateHostRpcAdmissionFailureResponse, + MAX_SESSION_TITLE_CODE_POINTS, + MAX_SESSION_TITLE_BYTES, + normalizeSessionTitle, + validateSessionRenameTitle, + validateHostModelSelectionRequest, + validateHostApprovalRequestId, + sessionsCursorScope, + decodeSessionsCursor, + encodeSessionsCursor, + validateHostActivityLimit, createMessageRetryHostWakeRequest, validateActionAuthorityHostWakeRequest, deriveMessageRetryHostWakeCheckpoint, diff --git a/packages/action-context-contracts/index.d.ts b/packages/action-context-contracts/index.d.ts index 43e61af03d..cd739956ed 100644 --- a/packages/action-context-contracts/index.d.ts +++ b/packages/action-context-contracts/index.d.ts @@ -274,6 +274,14 @@ export declare function validateHostMessageAdmissionFailureResponse( value: unknown ): HostMessageAdmissionFailureResponse +export type HostRpcAdmissionFailureResponse = + | Readonly<{ error: 'Too Many Requests'; retryAfterSeconds: number }> + | Readonly<{ error: 'host_rpc_admission_unavailable' }> + +export declare function validateHostRpcAdmissionFailureResponse( + value: unknown +): HostRpcAdmissionFailureResponse + export declare function validateHostMessageAdmissionContext( value: unknown ): HostMessageAdmissionCheckpointContext @@ -359,3 +367,48 @@ export declare function classifyMcpCallerOperation(input: { method: unknown params?: unknown }): McpCallerOperationClassification + +export declare const MAX_SESSION_TITLE_CODE_POINTS: 120 +export declare const MAX_SESSION_TITLE_BYTES: 512 +export declare function normalizeSessionTitle(input: string): string +export declare function validateSessionRenameTitle( + value: unknown +): Readonly<{ ok: true; title: string }> | Readonly<{ ok: false; error: 'invalid title' }> +export declare function validateHostModelSelectionRequest(value: unknown): + | Readonly<{ + ok: true + chatId: string + model: string + expectedRevision?: number + }> + | Readonly<{ + ok: false + error: + | 'chatId is required' + | 'model is required' + | 'expectedRevision must be a non-negative integer' + }> + +export declare function validateHostApprovalRequestId( + value: unknown +): + | Readonly<{ ok: true; requestId: unknown }> + | Readonly<{ ok: false; error: 'Missing userId or requestId' }> + +export type SessionsCursorWire = Readonly<{ + version: 1 + scope: string + updatedAt: string + key: string +}> +export declare function sessionsCursorScope(userSub: string, agent?: string): string +export declare function decodeSessionsCursor( + cursor: string | undefined, + expectedScope?: string +): SessionsCursorWire | null +export declare function encodeSessionsCursor(updatedAt: string, key: string, scope?: string): string +export declare function validateHostActivityLimit( + value: unknown +): + | Readonly<{ ok: true; limit: number }> + | Readonly<{ ok: false; error: 'limit must be a positive number' }> diff --git a/packages/action-context-contracts/index.test.cjs b/packages/action-context-contracts/index.test.cjs index 958010c776..be44fb2ac0 100644 --- a/packages/action-context-contracts/index.test.cjs +++ b/packages/action-context-contracts/index.test.cjs @@ -4,6 +4,87 @@ const assert = require('node:assert/strict') const test = require('node:test') const contracts = require('./index.cjs') +test('Host route deterministic validators preserve approval, model, and title rules', () => { + assert.deepEqual(contracts.validateHostModelSelectionRequest({}), { + ok: false, + error: 'chatId is required', + }) + assert.deepEqual(contracts.validateHostModelSelectionRequest({ chatId: ' c ', model: ' m ' }), { + ok: true, + chatId: 'c', + model: 'm', + }) + assert.equal( + contracts.validateHostModelSelectionRequest({ chatId: 'c', model: 'm', expectedRevision: -1 }) + .ok, + false + ) + assert.deepEqual(contracts.validateSessionRenameTitle(' A\nB '), { ok: true, title: 'A B' }) + assert.deepEqual(contracts.validateSessionRenameTitle(' '), { + ok: false, + error: 'invalid title', + }) + assert.deepEqual(contracts.validateHostApprovalRequestId('approval-1'), { + ok: true, + requestId: 'approval-1', + }) + assert.deepEqual(contracts.validateHostApprovalRequestId(''), { + ok: false, + error: 'Missing userId or requestId', + }) +}) + +test('overlong normalized titles skip unbounded code-point array materialization', () => { + const originalFrom = Array.from + let calls = 0 + let longResult + Array.from = function (...args) { + calls += 1 + return originalFrom.apply(this, args) + } + try { + longResult = contracts.validateSessionRenameTitle('😀'.repeat(121)) + } finally { + Array.from = originalFrom + } + + assert.deepEqual(longResult, { ok: false, error: 'invalid title' }) + assert.equal(calls, 0) + + assert.deepEqual(contracts.validateSessionRenameTitle('😀'.repeat(120)), { + ok: true, + title: '😀'.repeat(120), + }) +}) + +test('session cursor encoding and validation share the runtime wire contract', () => { + const scope = contracts.sessionsCursorScope('user-a', 'chatllm') + const cursor = contracts.encodeSessionsCursor('2026-04-22T00:00:00.000Z', 'session-a', scope) + assert.deepEqual(contracts.decodeSessionsCursor(cursor, scope), { + version: 1, + scope, + updatedAt: '2026-04-22T00:00:00.000Z', + key: 'session-a', + }) + assert.equal( + contracts.decodeSessionsCursor(cursor, contracts.sessionsCursorScope('user-b')), + null + ) + assert.equal(contracts.decodeSessionsCursor('not-json', scope), null) + assert.equal(contracts.decodeSessionsCursor('', scope), null) +}) + +test('Host activity limits preserve the producer range and reject invalid numbers', () => { + assert.deepEqual(contracts.validateHostActivityLimit(undefined), { ok: true, limit: 50 }) + assert.deepEqual(contracts.validateHostActivityLimit('25'), { ok: true, limit: 25 }) + for (const invalid of ['0', '-1', 'not-a-number', 'Infinity', 'NaN']) { + assert.deepEqual(contracts.validateHostActivityLimit(invalid), { + ok: false, + error: 'limit must be a positive number', + }) + } +}) + test('operation identifiers and generated v2 scopes are bijective', () => { assert.equal(new Set(contracts.ACTION_OPERATION_IDS).size, contracts.ACTION_OPERATION_IDS.length) assert.equal(contracts.ACTION_OPERATION_SCOPES.length, contracts.ACTION_OPERATION_IDS.length) @@ -316,6 +397,29 @@ test('Host-message checkpoint admission context and failures are exact bounded w retryAfterSeconds: 0, }) ) + assert.throws(() => + contracts.validateHostMessageAdmissionFailureResponse({ + error: 'host_rpc_admission_unavailable', + }) + ) + assert.deepEqual( + contracts.validateHostRpcAdmissionFailureResponse({ + error: 'host_rpc_admission_unavailable', + }), + { error: 'host_rpc_admission_unavailable' } + ) + assert.deepEqual( + contracts.validateHostRpcAdmissionFailureResponse({ + error: 'Too Many Requests', + retryAfterSeconds: 12, + }), + { error: 'Too Many Requests', retryAfterSeconds: 12 } + ) + assert.throws(() => + contracts.validateHostRpcAdmissionFailureResponse({ + error: 'host_message_admission_unavailable', + }) + ) }) test('message-retry wake contract is a separate same-Host source-binding variant', () => { diff --git a/rpc-proxy/package-lock.json b/rpc-proxy/package-lock.json index 6b2d3e46bb..36ef5f620c 100644 --- a/rpc-proxy/package-lock.json +++ b/rpc-proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.113", + "version": "0.1.123", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-rpc-proxy", - "version": "0.1.113", + "version": "0.1.123", "dependencies": { "@clerum/action-context-contracts": "file:../packages/action-context-contracts", "cors": "^2.8.5", diff --git a/rpc-proxy/package.json b/rpc-proxy/package.json index 7ffd21c9f0..baba9b46a3 100644 --- a/rpc-proxy/package.json +++ b/rpc-proxy/package.json @@ -1,6 +1,6 @@ { "name": "clerum-rpc-proxy", - "version": "0.1.113", + "version": "0.1.123", "description": "External-facing RPC proxy for tenant-scoped MCP access", "main": "dist/main.js", "engines": { diff --git a/rpc-proxy/src/__tests__/approval-artifacts.test.ts b/rpc-proxy/src/__tests__/approval-artifacts.test.ts index 4c99e7e005..9cf3046bf0 100644 --- a/rpc-proxy/src/__tests__/approval-artifacts.test.ts +++ b/rpc-proxy/src/__tests__/approval-artifacts.test.ts @@ -43,6 +43,7 @@ const controlApiMock = vi.hoisted(() => ({ // Stage 5 wake-and-hold consults the control-api wake endpoint whenever an // upstream host looks down. Defaulting to 409 not-stateless preserves the // legacy 502 contract for the host-down cases exercised in this file. + requestHostRpcAdmission: async () => undefined, requestHostWakeFromControlApi: vi.fn().mockResolvedValue({ kind: 'not-stateless' }), })) diff --git a/rpc-proxy/src/__tests__/host-ref-validation.test.ts b/rpc-proxy/src/__tests__/host-ref-validation.test.ts new file mode 100644 index 0000000000..6a1c6b1138 --- /dev/null +++ b/rpc-proxy/src/__tests__/host-ref-validation.test.ts @@ -0,0 +1,580 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import request from 'supertest' +import { createRpcRouter } from '../routes/rpc.js' +import { createRpcHostActivityStreamRouter } from '../routes/rpcHostActivityStream.js' +import { createRpcHostProgressStreamRouter } from '../routes/rpcHostProgressStream.js' +import { createRpcHostStatusStreamRouter } from '../routes/rpcHostStatusStream.js' + +const authMock = vi.hoisted(() => ({ verifyRpcToken: vi.fn() })) +const hostMock = vi.hoisted(() => ({ + resolveHostConnectionForUser: vi.fn(), + resolveArtifactReadHostConnectionForUser: vi.fn(), + requestHostWakeFromControlApi: vi.fn(), +})) +const admissionMock = vi.hoisted(() => ({ requestHostRpcAdmission: vi.fn() })) + +const repositoryRoot = resolve(process.cwd(), '..') +const tsx = resolve(repositoryRoot, 'rpc-proxy', 'node_modules', '.bin', 'tsx') +const hostWakeDelegationProducer = resolve( + repositoryRoot, + 'control-api', + 'test', + 'fixtures', + 'emitUserDelegationV2Fixture.ts' +) +const bodyBoundDelegationProducer = resolve( + repositoryRoot, + 'control-api', + 'test', + 'fixtures', + 'emitBodyBoundRouteDelegationV2Fixture.ts' +) + +vi.mock('../authToken.js', async importOriginal => ({ + ...(await importOriginal()), + verifyRpcToken: authMock.verifyRpcToken, +})) +vi.mock('../services/mcpProxyService.js', async importOriginal => ({ + ...(await importOriginal()), + resolveHostConnectionForUser: hostMock.resolveHostConnectionForUser, + resolveArtifactReadHostConnectionForUser: hostMock.resolveArtifactReadHostConnectionForUser, +})) +vi.mock('../services/controlApiRestService.js', async importOriginal => ({ + ...(await importOriginal()), + requestHostWakeFromControlApi: hostMock.requestHostWakeFromControlApi, + requestHostRpcAdmission: admissionMock.requestHostRpcAdmission, +})) + +const legacyClaims = { + sub: 'user-1', + typ: 'user' as const, + accessScope: 'team' as const, + teamId: 'team-1', + scopes: [ + 'host:message:invoke', + 'host:wake:write', + 'host:approval:write', + 'host:session:read', + 'host:session:write', + 'host:model:write', + 'host:task:read', + 'host:activity:read', + 'host:status:read', + 'host:health:read', + ], + hostRefs: ['host-a'], + jti: 'jti-1', + iat: 1, + exp: 9_999_999_999, +} + +function mountedApp() { + const app = express() + app.use(createRpcRouter()) + app.use(createRpcHostStatusStreamRouter()) + app.use(createRpcHostActivityStreamRouter()) + app.use(createRpcHostProgressStreamRouter()) + app.use((_req, res) => res.status(404).json({ error: 'Not Found' })) + return app +} + +type Route = { method: 'get' | 'post' | 'patch'; suffix: string; body?: object } +const w1PayloadRoutes: Route[] = [ + { + method: 'post', + suffix: '/approvals/approve', + body: { taskId: 'task-1', toolCallId: 'approval-1' }, + }, + { + method: 'post', + suffix: '/approvals/deny', + body: { taskId: 'task-1', toolCallId: 'approval-1' }, + }, + { + method: 'post', + suffix: '/model', + body: { agent: 'agent', chatId: 'chat', provider: 'openai', model: 'model' }, + }, + { method: 'patch', suffix: '/sessions/agent/chat/name', body: { title: 'Valid title' } }, +] +const legacyRoutes: Route[] = [ + { method: 'post', suffix: '/messages', body: { content: 'hello' } }, + { method: 'post', suffix: '/wake', body: {} }, + ...w1PayloadRoutes.slice(0, 2), + w1PayloadRoutes[2]!, + { method: 'post', suffix: '/tasks/task-1/cancel', body: {} }, + w1PayloadRoutes[3]!, + { method: 'get', suffix: '/sessions' }, + { method: 'get', suffix: '/sessions/agent/chat/messages' }, + { method: 'get', suffix: '/sessions/agent/chat/context-breakdown' }, + { method: 'get', suffix: '/models' }, + { method: 'get', suffix: '/tasks/task-1/result' }, + { method: 'get', suffix: '/artifacts' }, + { method: 'get', suffix: '/artifacts/file.txt/download' }, + { method: 'get', suffix: '/activity' }, + { method: 'get', suffix: '/status' }, + { method: 'get', suffix: '/health' }, + { method: 'get', suffix: '/status/stream' }, + { method: 'get', suffix: '/activity/stream' }, + { method: 'get', suffix: '/tasks/task-1/progress/stream' }, +] +const invalidCapturedRefs = [ + '*', + '%2A', + '%252A', + 'Host', + '-host', + 'host-', + 'bad.name', + 'bad%20name', + 'bad_host', + 'bad%2Fhost', + 'bad%0Ahost', + 'bad%00host', + 'h'.repeat(64), +] + +async function callRoute(app: express.Express, route: Route, encodedHostRef: string) { + const path = `/rpc/hosts/${encodedHostRef}${route.suffix}` + const call = request(app)[route.method](path).set('authorization', 'Bearer legacy-token') + return route.body === undefined ? call : call.send(route.body) +} + +beforeEach(() => { + vi.clearAllMocks() + authMock.verifyRpcToken.mockReturnValue(legacyClaims) + admissionMock.requestHostRpcAdmission.mockResolvedValue(undefined) + hostMock.resolveHostConnectionForUser.mockResolvedValue(null) + hostMock.resolveArtifactReadHostConnectionForUser.mockResolvedValue(null) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('public Host-ref validation', () => { + it('uses the Control API Host-create grammar exactly', async () => { + const { HOST_REF_RE } = await import('../middleware/hostRefValidation.js') + const authority = readFileSync( + resolve(process.cwd(), '../control-api/src/http/rfc1123.ts'), + 'utf8' + ) + const authoritativePattern = authority.match(/export const RFC1123_RE\s*=\s*(\/[^\n]+\/)/) + expect(authoritativePattern?.[1]).toBe(HOST_REF_RE.toString()) + }) + + it.each(legacyRoutes)( + '$method $suffix rejects invalid captured Host refs before resolution', + async route => { + const response = await callRoute(mountedApp(), route, 'invalid_host') + expect(response.status).toBe(400) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + expect(hostMock.resolveArtifactReadHostConnectionForUser).not.toHaveBeenCalled() + expect(hostMock.requestHostWakeFromControlApi).not.toHaveBeenCalled() + expect(admissionMock.requestHostRpcAdmission).not.toHaveBeenCalled() + } + ) + + it.each(legacyRoutes)( + '$method $suffix rejects every captured invalid Host before effects', + async route => { + for (const encodedHostRef of invalidCapturedRefs) { + vi.clearAllMocks() + const response = await callRoute(mountedApp(), route, encodedHostRef) + expect(response.status).toBe(400) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + expect(hostMock.resolveArtifactReadHostConnectionForUser).not.toHaveBeenCalled() + expect(hostMock.requestHostWakeFromControlApi).not.toHaveBeenCalled() + expect(admissionMock.requestHostRpcAdmission).not.toHaveBeenCalled() + } + } + ) + + it.each(w1PayloadRoutes)( + 'rejects invalid Host at W1 for valid $method $suffix input before admission or effects', + async route => { + const downstreamEffect = vi.fn() + vi.stubGlobal('fetch', downstreamEffect) + const response = await callRoute(mountedApp(), route, 'invalid_host') + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid hostRef' }) + expect(admissionMock.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + expect(hostMock.resolveArtifactReadHostConnectionForUser).not.toHaveBeenCalled() + expect(hostMock.requestHostWakeFromControlApi).not.toHaveBeenCalled() + expect(downstreamEffect).not.toHaveBeenCalled() + } + ) + + it.each(w1PayloadRoutes)( + 'valid Host control reaches live authority after preflight for $method $suffix', + async route => { + authMock.verifyRpcToken.mockReturnValue({ ...legacyClaims, hostRefs: ['valid-host'] }) + const downstreamEffect = vi.fn() + vi.stubGlobal('fetch', downstreamEffect) + const response = await callRoute(mountedApp(), route, 'valid-host') + + expect(response.status).toBe(403) + expect(response.body).toEqual({ error: 'Forbidden: user cannot access this host' }) + expect(admissionMock.requestHostRpcAdmission).toHaveBeenCalledTimes(1) + expect(hostMock.resolveHostConnectionForUser).toHaveBeenCalledWith( + 'user-1', + 'valid-host', + 'legacy-token', + expect.anything() + ) + expect(downstreamEffect).not.toHaveBeenCalled() + } + ) + + it.each([ + { + name: 'approval approve', + operationId: 'task.manage', + resourceType: 'runtime_session', + resourceId: 'session-a', + target: { + hostRef: 'mcp-host/chatllm', + taskId: '60000000-0000-4000-8000-000000000006', + action: 'approve', + approvalRequestId: '70000000-0000-4000-8000-000000000007', + }, + suffix: '/approvals/approve', + body: { + taskId: '60000000-0000-4000-8000-000000000006', + toolCallId: '70000000-0000-4000-8000-000000000007', + }, + }, + { + name: 'approval deny', + operationId: 'task.manage', + resourceType: 'runtime_session', + resourceId: 'session-a', + target: { + hostRef: 'mcp-host/chatllm', + taskId: '60000000-0000-4000-8000-000000000006', + action: 'deny', + approvalRequestId: '70000000-0000-4000-8000-000000000007', + }, + suffix: '/approvals/deny', + body: { + taskId: '60000000-0000-4000-8000-000000000006', + toolCallId: '70000000-0000-4000-8000-000000000007', + }, + }, + { + name: 'model write', + operationId: 'model.select', + resourceType: 'runtime_session', + resourceId: 'session-a', + target: { + hostRef: 'mcp-host/chatllm', + agent: 'agent', + chatId: 'chat', + provider: 'openai', + model: 'model', + }, + suffix: '/model', + body: { agent: 'agent', chatId: 'chat', provider: 'openai', model: 'model' }, + }, + ])('rejects producer-bound v2 $name at W1 before checkpoint or effects', async route => { + const producerOutput = execFileSync( + tsx, + [ + bodyBoundDelegationProducer, + JSON.stringify({ + operationId: route.operationId, + resourceType: route.resourceType, + resourceId: route.resourceId, + target: route.target, + }), + ], + { cwd: repositoryRoot, encoding: 'utf8', env: process.env } + ) + const { token } = JSON.parse(producerOutput) as { token: string } + const checkpoint = vi.fn( + async () => + new Response(JSON.stringify({ error: 'checkpoint_should_not_run' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', checkpoint) + + const response = await request(mountedApp()) + .post(`/rpc/hosts/%20chatllm%20${route.suffix}`) + .set('authorization', `Bearer ${token}`) + .send(route.body) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid hostRef' }) + expect(checkpoint).not.toHaveBeenCalled() + expect(admissionMock.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + expect(hostMock.requestHostWakeFromControlApi).not.toHaveBeenCalled() + }) + + it.each(['a', 'a'.repeat(63), 'valid-host-1'])( + 'allows valid boundary %s to reach the existing live authority path', + async hostRef => { + authMock.verifyRpcToken.mockReturnValue({ ...legacyClaims, hostRefs: [hostRef] }) + const response = await callRoute(mountedApp(), { method: 'get', suffix: '/status' }, hostRef) + expect(response.status).toBe(403) + expect(hostMock.resolveHostConnectionForUser).toHaveBeenCalledWith( + 'user-1', + hostRef, + 'legacy-token', + expect.anything() + ) + expect(admissionMock.requestHostRpcAdmission).toHaveBeenCalledTimes(1) + } + ) + + it.each([legacyRoutes[5], legacyRoutes[11]])( + 'retains 404 for a valid but inaccessible $suffix task Host', + async route => { + const response = await callRoute(mountedApp(), route, 'valid-host') + expect(response.status).toBe(404) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + } + ) + + it('keeps the v2-only session-search denial before W1 for a legacy token', async () => { + const response = await request(mountedApp()) + .get('/rpc/hosts/invalid_host/sessions/search?q=x') + .set('authorization', 'Bearer legacy-token') + expect(response.status).toBe(403) + expect(response.body).toEqual({ error: 'User delegation v2 required for session search' }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('keeps authentication and scope denial ahead of W1', async () => { + await request(mountedApp()).get('/rpc/hosts/invalid_host/status').expect(401) + authMock.verifyRpcToken.mockReturnValue({ ...legacyClaims, scopes: [] }) + await request(mountedApp()) + .get('/rpc/hosts/invalid_host/status') + .set('authorization', 'Bearer legacy-token') + .expect(403) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('rejects a whitespace Host ref after real v2 binding but before the remote checkpoint', async () => { + const producerOutput = execFileSync(tsx, [hostWakeDelegationProducer], { + cwd: repositoryRoot, + encoding: 'utf8', + env: process.env, + }) + const { token } = JSON.parse(producerOutput) as { token: string } + const remoteCheckpoint = vi.fn( + async () => + new Response(JSON.stringify({ error: 'checkpoint_should_not_run' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', remoteCheckpoint) + + // The real producer signs mcp-host/chatllm. The local route binder's + // existing trim makes this exact target match, while W1 must inspect and + // reject the captured bare segment without normalization. + const response = await request(mountedApp()) + .post('/rpc/hosts/%20chatllm%20/wake') + .set('authorization', `Bearer ${token}`) + .send({ wakeReason: 'explicit' }) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid hostRef' }) + expect(remoteCheckpoint).not.toHaveBeenCalled() + expect(hostMock.requestHostWakeFromControlApi).not.toHaveBeenCalled() + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('rejects real v2 target substitutions during local binding before W1 or checkpoint I/O', async () => { + const producerOutput = execFileSync(tsx, [hostWakeDelegationProducer], { + cwd: repositoryRoot, + encoding: 'utf8', + env: process.env, + }) + const { token } = JSON.parse(producerOutput) as { token: string } + const remoteCheckpoint = vi.fn() + vi.stubGlobal('fetch', remoteCheckpoint) + + for (const routeHost of ['other-host', 'invalid_host']) { + const response = await request(mountedApp()) + .post(`/rpc/hosts/${routeHost}/wake`) + .set('authorization', `Bearer ${token}`) + .send({ wakeReason: 'explicit' }) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'invalid_binding' }) + } + expect(remoteCheckpoint).not.toHaveBeenCalled() + expect(hostMock.requestHostWakeFromControlApi).not.toHaveBeenCalled() + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('runs message preflight and W1 after exact binding but before the remote checkpoint', async () => { + const producerOutput = execFileSync( + tsx, + [ + bodyBoundDelegationProducer, + JSON.stringify({ + operationId: 'chat.message.invoke', + resourceType: 'host', + resourceId: 'mcp-host/chatllm', + target: { + hostRef: 'mcp-host/chatllm', + channelType: 'rpc', + channelId: 'chatllm', + }, + }), + ], + { cwd: repositoryRoot, encoding: 'utf8', env: process.env } + ) + const fixture = JSON.parse(producerOutput) as { token: string; messageId: string } + const checkpoint = vi.fn() + vi.stubGlobal('fetch', checkpoint) + + const invalidRef = await request(mountedApp()) + .post('/rpc/hosts/%20chatllm%20/messages') + .set('authorization', `Bearer ${fixture.token}`) + .send({ content: 'hello', messageId: fixture.messageId }) + expect(invalidRef.status).toBe(400) + expect(invalidRef.body).toEqual({ error: 'Invalid hostRef' }) + expect(checkpoint).not.toHaveBeenCalled() + + const invalidMessageAndRef = await request(mountedApp()) + .post('/rpc/hosts/%20chatllm%20/messages') + .set('authorization', `Bearer ${fixture.token}`) + .send({ content: '', messageId: fixture.messageId }) + expect(invalidMessageAndRef.status).toBe(400) + expect(invalidMessageAndRef.body).toEqual({ error: 'Invalid host message request payload' }) + expect(checkpoint).not.toHaveBeenCalled() + + const invalidMessage = await request(mountedApp()) + .post('/rpc/hosts/chatllm/messages') + .set('authorization', `Bearer ${fixture.token}`) + .send({ content: '', messageId: fixture.messageId }) + expect(invalidMessage.status).toBe(400) + expect(invalidMessage.body).toEqual({ error: 'Invalid host message request payload' }) + expect(checkpoint).not.toHaveBeenCalled() + }) + + it('does not convert a missing segment into a captured invalid Host', async () => { + await request(mountedApp()) + .get('/rpc/hosts//status') + .set('authorization', 'Bearer legacy-token') + .expect(404) + }) + + it('preserves malformed JSON precedence for a legacy session rename', async () => { + const response = await request(mountedApp()) + .patch('/rpc/hosts/invalid_host/sessions/agent/chat/name') + .set('authorization', 'Bearer legacy-token') + .set('content-type', 'application/json') + .send('{') + expect(response.status).toBe(400) + expect(response.text).not.toContain('Invalid hostRef') + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('returns W1 400 after valid JSON on a legacy session rename', async () => { + const response = await request(mountedApp()) + .patch('/rpc/hosts/invalid_host/sessions/agent/chat/name') + .set('authorization', 'Bearer legacy-token') + .send({ title: 'Valid title' }) + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid hostRef' }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it.each([ + { method: 'get' as const, suffix: '/tasks/bad.id/result' }, + { method: 'post' as const, suffix: '/tasks/bad.id/cancel' }, + ])('keeps task-ID denial before W1 on $method $suffix', async route => { + const response = await callRoute(mountedApp(), route, 'invalid_host') + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid taskId' }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('keeps progress task-ID denial before W1', async () => { + const response = await callRoute( + mountedApp(), + { method: 'get', suffix: '/tasks/bad.id/progress/stream' }, + 'invalid_host' + ) + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'taskId is required and must be alphanumeric (max 128 chars)', + }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it.each([ + { + suffix: '/status/stream', + error: 'Status stream is read-only and does not accept request bodies', + }, + { + suffix: '/activity/stream', + error: 'Activity stream is read-only and does not accept request bodies', + }, + { + suffix: '/tasks/task-1/progress/stream', + error: 'Progress stream is read-only and does not accept request bodies', + }, + ])('keeps read-only body denial before W1 on $suffix', async route => { + const response = await request(mountedApp()) + .get(`/rpc/hosts/invalid_host${route.suffix}`) + .set('authorization', 'Bearer legacy-token') + .set('content-length', '1') + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: route.error }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it.each([ + { suffix: '/sessions?limit=0', error: 'Invalid session pagination query' }, + { suffix: '/sessions/bad:agent/chat/messages', error: 'Invalid hostRef, agent, or chatId' }, + { + suffix: '/sessions/agent/bad%2Fchat/context-breakdown', + error: 'Invalid hostRef, agent, or chatId', + }, + ])('keeps pre-resolution session validation before W1 on $suffix', async route => { + const response = await request(mountedApp()) + .get(`/rpc/hosts/invalid_host${route.suffix}`) + .set('authorization', 'Bearer legacy-token') + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: route.error }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('keeps message payload denial before W1', async () => { + const response = await request(mountedApp()) + .post('/rpc/hosts/invalid_host/messages') + .set('authorization', 'Bearer legacy-token') + .send({ content: '' }) + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Invalid host message request payload' }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('keeps message attachment denial before W1', async () => { + const response = await request(mountedApp()) + .post('/rpc/hosts/invalid_host/messages') + .set('authorization', 'Bearer legacy-token') + .send({ content: 'hello', attachments: 'invalid' }) + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'invalid_attachments', + message: 'Attachments must be a list.', + }) + expect(hostMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) +}) diff --git a/rpc-proxy/src/__tests__/host-rpc-admission-order.producer.test.ts b/rpc-proxy/src/__tests__/host-rpc-admission-order.producer.test.ts new file mode 100644 index 0000000000..08c6ea199b --- /dev/null +++ b/rpc-proxy/src/__tests__/host-rpc-admission-order.producer.test.ts @@ -0,0 +1,636 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { execFileSync } from 'node:child_process' +import { createServer, request as rawHttpRequest } from 'node:http' +import { resolve } from 'node:path' +import request from 'supertest' +import { encodeSessionsCursor, sessionsCursorScope } from '@clerum/action-context-contracts' +import { signRpcAccessToken } from '../../../control-api/src/utils/auth/rpcAuthToken.js' + +const events = vi.hoisted(() => ({ values: [] as string[] })) +const service = vi.hoisted(() => ({ + forwardCancelToHost: vi.fn(), + forwardHostActivity: vi.fn(), + forwardHostHealth: vi.fn(), + forwardHostMessageToHost: vi.fn(), + forwardHostStatus: vi.fn(), + forwardRpcToServer: vi.fn(), + forwardTaskResultFromHost: vi.fn(), + listAllowedServersForUser: vi.fn(), + resolveArtifactReadHostConnectionForUser: vi.fn(), + resolveHostConnectionForUser: vi.fn(), + resolveServerConnectionForUser: vi.fn(), + validateRpcRequest: vi.fn(), + UpstreamHostError: class UpstreamHostError extends Error { + constructor( + public readonly status: number, + public readonly bodySnippet: string + ) { + super(`Upstream host returned ${status}: ${bodySnippet}`) + this.name = 'UpstreamHostError' + } + }, +})) +const controlApi = vi.hoisted(() => ({ + ControlApiArtifactReadRateLimitedError: class extends Error {}, + ControlApiConnectorsRejectedError: class extends Error {}, + ControlApiHostAccessRejectedError: class extends Error {}, + ControlApiHostMessageAdmissionError: class extends Error {}, + ControlApiHostRpcAdmissionError: class extends Error { + constructor( + readonly status: 429 | 503, + readonly body: { error: string; retryAfterSeconds?: number }, + readonly headers: Record + ) { + super('Host-RPC admission rejected') + this.name = 'ControlApiHostRpcAdmissionError' + } + }, + fetchUserConnectorsFromControlApi: vi.fn(), + fetchUserAllowedServersFromControlApi: vi.fn(), + requestHostWakeFromControlApi: vi.fn(), + requestHostRpcAdmission: vi.fn(), +})) + +vi.mock('../services/mcpProxyService.js', () => service) +vi.mock('../services/controlApiRestService.js', () => controlApi) + +const { createRpcRouter } = await import('../routes/rpc.js') +const { createRpcHostStatusStreamRouter } = await import('../routes/rpcHostStatusStream.js') +const { createRpcHostActivityStreamRouter } = await import('../routes/rpcHostActivityStream.js') +const { createRpcHostProgressStreamRouter } = await import('../routes/rpcHostProgressStream.js') + +const USER = '00000000-0000-4000-8000-000000000001' +const TASK = '40000000-0000-4000-8000-000000000001' +const CHAT = '40000000-0000-4000-8000-000000000002' +const REPOSITORY_ROOT = resolve(process.cwd(), '..') +const TSX = resolve(REPOSITORY_ROOT, 'rpc-proxy/node_modules/.bin/tsx') +const RUNTIME_SESSION_PRODUCER = resolve( + REPOSITORY_ROOT, + 'control-api/test/fixtures/emitRuntimeSessionDelegationV2Fixture.ts' +) +const TEAM_RUNTIME_PRODUCER = resolve( + REPOSITORY_ROOT, + 'control-api/test/fixtures/emitTeamDerivedRuntimeDelegationV2Fixture.ts' +) +const signedAccessToken = signRpcAccessToken({ + sub: USER, + typ: 'user', + accessScope: 'team', + teamId: 'team-a', + scopes: [ + 'host:health:read', + 'host:status:read', + 'host:activity:read', + 'host:message:invoke', + 'host:wake:write', + 'host:task:read', + 'host:approval:write', + 'host:session:read', + 'host:session:write', + 'host:model:write', + 'host:model:read', + ], + hostRefs: ['chatllm'], + jti: 'producer-token-jti', +}) + +const CASES = [ + { + name: 'wake', + path: '/rpc/hosts/chatllm/wake', + method: 'post' as const, + body: {}, + }, + { + name: 'approval write', + path: '/rpc/hosts/chatllm/approvals/approve', + method: 'post' as const, + body: { taskId: TASK, toolCallId: 'approval-1' }, + }, + { + name: 'approval deny with requestId fallback', + path: '/rpc/hosts/chatllm/approvals/deny', + method: 'post' as const, + body: { taskId: TASK, toolCallId: '', requestId: 'approval-2' }, + }, + { name: 'session read', path: '/rpc/hosts/chatllm/sessions', method: 'get' as const }, + { + name: 'session transcript read', + path: `/rpc/hosts/chatllm/sessions/chatllm/${CHAT}/messages`, + method: 'get' as const, + }, + { + name: 'session context breakdown read', + path: `/rpc/hosts/chatllm/sessions/chatllm/${CHAT}/context-breakdown`, + method: 'get' as const, + }, + { + name: 'session rename', + path: `/rpc/hosts/chatllm/sessions/chatllm/${CHAT}/name`, + method: 'patch' as const, + body: { title: 'Renamed session' }, + }, + { name: 'model catalog read', path: '/rpc/hosts/chatllm/models', method: 'get' as const }, + { + name: 'model write', + path: '/rpc/hosts/chatllm/model', + method: 'post' as const, + body: { agent: 'chatllm', chatId: CHAT, provider: 'openai', model: 'm1' }, + }, + { + name: 'task read', + path: `/rpc/hosts/chatllm/tasks/${TASK}/result`, + method: 'get' as const, + expectedStatus: 404, + }, + { + name: 'task cancel', + path: `/rpc/hosts/chatllm/tasks/${TASK}/cancel`, + method: 'post' as const, + expectedStatus: 404, + }, + { name: 'telemetry read', path: '/rpc/hosts/chatllm/health', method: 'get' as const }, + { name: 'status snapshot read', path: '/rpc/hosts/chatllm/status', method: 'get' as const }, + { + name: 'activity snapshot read', + path: '/rpc/hosts/chatllm/activity?limit=50', + method: 'get' as const, + }, + { name: 'status stream open', path: '/rpc/hosts/chatllm/status/stream', method: 'get' as const }, + { + name: 'activity stream open', + path: '/rpc/hosts/chatllm/activity/stream', + method: 'get' as const, + }, + { + name: 'task progress stream open', + path: `/rpc/hosts/chatllm/tasks/${TASK}/progress/stream`, + method: 'get' as const, + }, +] as const + +function app() { + const server = express() + server.use(express.json()) + server.use(createRpcRouter()) + server.use(createRpcHostStatusStreamRouter()) + server.use(createRpcHostActivityStreamRouter()) + server.use(createRpcHostProgressStreamRouter()) + return server +} + +async function rawChunkedGet(path: string): Promise<{ + status: number + body: unknown + contentLength: string | number | undefined + transferEncoding: string | number | undefined +}> { + const server = createServer(app()) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('test server did not bind TCP') + + try { + return await new Promise((resolveResponse, rejectResponse) => { + const client = rawHttpRequest( + { + host: '127.0.0.1', + port: address.port, + path, + method: 'GET', + headers: { + authorization: `Bearer ${signedAccessToken}`, + 'content-type': 'application/json', + 'transfer-encoding': 'chunked', + connection: 'close', + }, + }, + response => { + let text = '' + response.setEncoding('utf8') + response.on('data', chunk => (text += chunk)) + response.on('end', () => { + resolveResponse({ + status: response.statusCode ?? 0, + body: JSON.parse(text), + contentLength: client.getHeader('content-length'), + transferEncoding: client.getHeader('transfer-encoding'), + }) + }) + } + ) + client.on('error', rejectResponse) + client.write('{"unexpected":') + client.end('true}') + }) + } finally { + await new Promise((resolveClose, rejectClose) => + server.close(error => (error ? rejectClose(error) : resolveClose())) + ) + } +} + +beforeEach(() => { + vi.clearAllMocks() + events.values.length = 0 + controlApi.requestHostRpcAdmission.mockImplementation(async () => { + events.values.push('admission') + }) + service.resolveHostConnectionForUser.mockImplementation(async () => { + events.values.push('live-resolver') + return null + }) +}) + +describe('Spec 65 legacy Host-RPC admission ordering (real Control API token producer)', () => { + it.each(CASES)('admits the $name route before live Host resolution', async route => { + const testRequest = request(app()) + [route.method](route.path) + .set('authorization', `Bearer ${signedAccessToken}`) + if ('body' in route && route.body) testRequest.send(route.body) + await testRequest.expect('expectedStatus' in route ? route.expectedStatus : 403) + expect(events.values).toEqual(['admission', 'live-resolver']) + }) + + it('returns canonical request-N+1 429 before live Host resolution', async () => { + controlApi.requestHostRpcAdmission.mockImplementation(async () => { + events.values.push('admission') + throw new controlApi.ControlApiHostRpcAdmissionError( + 429, + { error: 'Too Many Requests', retryAfterSeconds: 22 }, + { + 'Retry-After': '22', + 'X-RateLimit-Limit': '300', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': '1900000000', + } + ) + }) + const response = await request(app()) + .get('/rpc/hosts/chatllm/health') + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(429) + expect(response.body).toEqual({ error: 'Too Many Requests', retryAfterSeconds: 22 }) + expect(response.headers['retry-after']).toBe('22') + expect(response.headers['x-ratelimit-limit']).toBe('300') + expect(response.headers['x-ratelimit-remaining']).toBe('0') + expect(response.headers['x-ratelimit-reset']).toBe('1900000000') + expect(events.values).toEqual(['admission']) + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('does not charge invalid authentication or insufficient scope', async () => { + const noScopeToken = signRpcAccessToken({ + sub: USER, + typ: 'user', + accessScope: 'team', + teamId: 'team-a', + scopes: ['mcp:servers:list'], + hostRefs: ['chatllm'], + jti: 'producer-no-host-scope', + }) + await request(app()) + .get('/rpc/hosts/chatllm/health') + .set('authorization', 'Bearer invalid') + .expect(401) + await request(app()) + .get('/rpc/hosts/chatllm/health') + .set('authorization', `Bearer ${noScopeToken}`) + .expect(403) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('rejects a malformed sessions cursor before admission and live Host work', async () => { + const response = await request(app()) + .get('/rpc/hosts/chatllm/sessions?cursor=not-json') + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(400) + expect(response.body).toEqual({ error: 'Invalid sessions cursor' }) + expect(events.values).toEqual([]) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('admits a session list request with a Control API-compatible cursor', async () => { + const cursor = encodeSessionsCursor( + '2026-04-22T00:00:00.000Z', + 'session-a', + sessionsCursorScope(USER) + ) + await request(app()) + .get(`/rpc/hosts/chatllm/sessions?cursor=${encodeURIComponent(cursor)}`) + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(403) + expect(events.values).toEqual(['admission', 'live-resolver']) + }) + + it.each(['0', '-1', 'not-a-number', 'Infinity', 'NaN'])( + 'rejects invalid activity limit %s before admission and live Host work', + async limit => { + const response = await request(app()) + .get(`/rpc/hosts/chatllm/activity?limit=${encodeURIComponent(limit)}`) + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(400) + expect(response.body).toEqual({ error: 'limit must be a positive number' }) + expect(events.values).toEqual([]) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + } + ) + + it('admits a valid activity limit once before live Host resolution', async () => { + await request(app()) + .get('/rpc/hosts/chatllm/activity?limit=25') + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(403) + expect(events.values).toEqual(['admission', 'live-resolver']) + }) + + it.each(['%2A', 'bad%25ref', 'bad%2Fref'])( + 'rejects malformed progress-stream Host ref %s before admission and resolution', + async encodedHostRef => { + const response = await request(app()) + .get(`/rpc/hosts/${encodedHostRef}/tasks/${TASK}/progress/stream`) + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(400) + expect(response.body).toEqual({ error: 'Invalid hostRef' }) + expect(events.values).toEqual([]) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + } + ) + + it('admits a valid progress-stream Host ref before live resolution', async () => { + await request(app()) + .get(`/rpc/hosts/chatllm/tasks/${TASK}/progress/stream`) + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(403) + expect(events.values).toEqual(['admission', 'live-resolver']) + }) + + it('rejects invalid legacy route input after scope but before admission or live resolution', async () => { + const response = await request(app()) + .get('/rpc/hosts/chatllm/sessions?limit=not-a-number') + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(400) + expect(response.body).toEqual({ error: 'Invalid session pagination query' }) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('preserves session path-error precedence over malformed pagination before admission', async () => { + const invalidPath = await request(app()) + .get('/rpc/hosts/chat%2Fllm/sessions/chatllm/session-a/messages?limit=not-a-number') + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(400) + + expect(invalidPath.body).toEqual({ error: 'Invalid hostRef, agent, or chatId' }) + expect(events.values).toEqual([]) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + + const invalidQuery = await request(app()) + .get('/rpc/hosts/chatllm/sessions/chatllm/session-a/messages?limit=not-a-number') + .set('authorization', `Bearer ${signedAccessToken}`) + .expect(400) + + expect(invalidQuery.body).toEqual({ error: 'Invalid session messages pagination query' }) + expect(events.values).toEqual([]) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('rejects a chunked read-only stream body before admission or Host resolution', async () => { + const response = await rawChunkedGet('/rpc/hosts/chatllm/status/stream') + + expect(response.contentLength).toBeUndefined() + expect(response.transferEncoding).toBe('chunked') + expect(events.values).toEqual([]) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'Status stream is read-only and does not accept request bodies', + }) + }) + + it.each([ + { + name: 'approval write without an effective request ID', + method: 'post' as const, + path: '/rpc/hosts/chatllm/approvals/approve', + body: { taskId: TASK }, + expectedBody: { error: 'Missing userId or requestId' }, + }, + { + name: 'approval deny without an effective request ID', + method: 'post' as const, + path: '/rpc/hosts/chatllm/approvals/deny', + body: { taskId: TASK, toolCallId: '', requestId: '' }, + expectedBody: { error: 'Missing userId or requestId' }, + }, + { + name: 'model write body shape', + method: 'post' as const, + path: '/rpc/hosts/chatllm/model', + body: [], + expectedBody: { error: 'Invalid set-model request payload' }, + }, + { + name: 'model write required fields', + method: 'post' as const, + path: '/rpc/hosts/chatllm/model', + body: {}, + expectedBody: { error: 'chatId is required' }, + }, + { + name: 'session rename invalid title', + method: 'patch' as const, + path: '/rpc/hosts/chatllm/sessions/chatllm/chat-1/name', + body: { title: ' ' }, + expectedBody: { error: 'invalid title' }, + }, + { + name: 'task identifier syntax', + method: 'get' as const, + path: '/rpc/hosts/chatllm/tasks/bad%21id/result', + expectedBody: { error: 'Invalid taskId' }, + }, + { + name: 'progress stream task identifier syntax', + method: 'get' as const, + path: '/rpc/hosts/chatllm/tasks/bad%21id/progress/stream', + expectedBody: { + error: 'taskId is required and must be alphanumeric (max 128 chars)', + }, + }, + { + name: 'read-only stream body shape', + method: 'get' as const, + path: '/rpc/hosts/chatllm/status/stream', + body: { unexpected: true }, + expectedBody: { error: 'Status stream is read-only and does not accept request bodies' }, + }, + ])('rejects invalid legacy $name before admission or live resolution', async item => { + const testRequest = request(app()) + [item.method](item.path) + .set('authorization', `Bearer ${signedAccessToken}`) + if (item.body) testRequest.send(item.body) + const response = await testRequest.expect(400) + expect(response.body).toEqual(item.expectedBody) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + }) + + it('preserves legacy scope denial before route preflight', async () => { + const noScopeToken = signRpcAccessToken({ + sub: USER, + typ: 'user', + accessScope: 'team', + teamId: 'team-a', + scopes: ['mcp:servers:list'], + hostRefs: ['chatllm'], + jti: 'producer-no-session-scope', + }) + const response = await request(app()) + .get('/rpc/hosts/chatllm/sessions?limit=not-a-number') + .set('authorization', `Bearer ${noScopeToken}`) + .expect(403) + expect(response.body).toEqual({ error: 'Forbidden: missing scope' }) + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + }) + + it('rejects invalid v2 route input after real local binding and before the remote checkpoint', async () => { + const fixture = JSON.parse( + execFileSync(TSX, [RUNTIME_SESSION_PRODUCER, 'session.read'], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + env: process.env, + }) + ) as { token: string } + const fetchMock = vi.fn(async () => new Response('{}', { status: 503 })) + vi.stubGlobal('fetch', fetchMock) + try { + const response = await request(app()) + .get('/rpc/hosts/chatllm/sessions/search?q=') + .set('authorization', `Bearer ${fixture.token}`) + .expect(400) + expect(response.body).toEqual({ error: 'Invalid session search query' }) + expect(fetchMock).not.toHaveBeenCalled() + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('preflights invalid v2 session rename after binding and before checkpoint or admission', async () => { + const fixture = JSON.parse( + execFileSync(TSX, [RUNTIME_SESSION_PRODUCER, 'session.manage'], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + env: process.env, + }) + ) as { token: string } + const fetchMock = vi.fn(async () => new Response('{}', { status: 503 })) + vi.stubGlobal('fetch', fetchMock) + try { + const response = await request(app()) + .patch('/rpc/hosts/chatllm/sessions/agent-a/chat-a/name') + .set('authorization', `Bearer ${fixture.token}`) + .send({ title: ' ' }) + .expect(400) + expect(response.body).toEqual({ error: 'invalid title' }) + expect(fetchMock).not.toHaveBeenCalled() + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('preserves exact v2 model binding denial before invalid model-request preflight', async () => { + const fixture = JSON.parse( + execFileSync(TSX, [RUNTIME_SESSION_PRODUCER, 'model.select'], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + env: process.env, + }) + ) as { token: string } + const fetchMock = vi.fn(async () => new Response('{}', { status: 503 })) + vi.stubGlobal('fetch', fetchMock) + try { + const response = await request(app()) + .post('/rpc/hosts/chatllm/model') + .set('authorization', `Bearer ${fixture.token}`) + .send({}) + .expect(400) + expect(response.body).toEqual({ error: 'invalid_binding' }) + expect(fetchMock).not.toHaveBeenCalled() + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('preserves exact v2 binding denial before invalid route preflight', async () => { + const fixture = JSON.parse( + execFileSync(TSX, [RUNTIME_SESSION_PRODUCER, 'session.read'], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + env: process.env, + }) + ) as { token: string } + const fetchMock = vi.fn(async () => new Response('{}', { status: 503 })) + vi.stubGlobal('fetch', fetchMock) + try { + const response = await request(app()) + .get('/rpc/hosts/other-host/sessions/search?q=') + .set('authorization', `Bearer ${fixture.token}`) + .expect(400) + expect(response.body).toEqual({ error: 'invalid_binding' }) + expect(fetchMock).not.toHaveBeenCalled() + expect(controlApi.requestHostRpcAdmission).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('keeps exact v2 binding ahead of stream-body preflight and preflights before checkpoint', async () => { + const fixture = JSON.parse( + execFileSync(TSX, [TEAM_RUNTIME_PRODUCER], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + env: process.env, + }) + ) as { token: string } + const fetchMock = vi.fn(async () => new Response('{}', { status: 503 })) + vi.stubGlobal('fetch', fetchMock) + try { + const invalidPreflight = await request(app()) + .get('/rpc/hosts/chatllm/status/stream') + .set('authorization', `Bearer ${fixture.token}`) + .send({ unexpected: true }) + .expect(400) + expect(invalidPreflight.body).toEqual({ + error: 'Status stream is read-only and does not accept request bodies', + }) + expect(fetchMock).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + + const invalidBinding = await request(app()) + .get('/rpc/hosts/other-host/status/stream') + .set('authorization', `Bearer ${fixture.token}`) + .send({ unexpected: true }) + .expect(400) + expect(invalidBinding.body).toEqual({ error: 'invalid_binding' }) + expect(fetchMock).not.toHaveBeenCalled() + expect(service.resolveHostConnectionForUser).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) +}) diff --git a/rpc-proxy/src/__tests__/host-rpc-admission-transport.test.ts b/rpc-proxy/src/__tests__/host-rpc-admission-transport.test.ts new file mode 100644 index 0000000000..f20143fd48 --- /dev/null +++ b/rpc-proxy/src/__tests__/host-rpc-admission-transport.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ControlApiHostRpcAdmissionError, + requestHostRpcAdmission, +} from '../services/controlApiRestService.js' + +describe('Spec 65 Control API Host-RPC admission transport', () => { + it('sends only verified identity selectors to the dedicated internal POST', async () => { + const fetchImpl = vi.fn( + async () => new Response(null, { status: 204 }) + ) as unknown as typeof fetch + await requestHostRpcAdmission('subject-a', 'host-a', 'rpc-token', { fetchImpl }) + expect(fetchImpl).toHaveBeenCalledOnce() + const [url, init] = vi.mocked(fetchImpl).mock.calls[0]! + expect(String(url)).toMatch( + /\/rpc\/access\/users\/subject-a\/mcp-hosts\/host-a\/host-rpc-admission$/ + ) + expect(init).toMatchObject({ method: 'POST' }) + expect(init?.body).toBeUndefined() + }) + + it('preserves canonical 429 body and every rate-limit header', async () => { + const fetchImpl = vi.fn(async () => + Response.json( + { error: 'Too Many Requests', retryAfterSeconds: 17 }, + { + status: 429, + headers: { + 'Retry-After': '17', + 'X-RateLimit-Limit': '300', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': '1900000000', + }, + } + ) + ) as unknown as typeof fetch + await expect( + requestHostRpcAdmission('subject-a', 'host-a', 'rpc-token', { fetchImpl }) + ).rejects.toMatchObject({ + name: 'ControlApiHostRpcAdmissionError', + status: 429, + body: { error: 'Too Many Requests', retryAfterSeconds: 17 }, + headers: { + 'Retry-After': '17', + 'X-RateLimit-Limit': '300', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': '1900000000', + }, + } satisfies Partial) + }) + + it.each([ + ['Retry-After', undefined], + ['X-RateLimit-Limit', undefined], + ['X-RateLimit-Remaining', undefined], + ['X-RateLimit-Reset', undefined], + ['X-RateLimit-Remaining', '0x0'], + ['X-RateLimit-Limit', '0x12'], + ['X-RateLimit-Reset', '0x1'], + ])('rejects missing or non-canonical %s from the Control API producer', async (name, value) => { + const headers: Record = { + 'Retry-After': '17', + 'X-RateLimit-Limit': '300', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': '1900000000', + } + if (value === undefined) delete headers[name!] + else headers[name!] = value + const fetchImpl = vi.fn(async () => + Response.json({ error: 'Too Many Requests', retryAfterSeconds: 17 }, { status: 429, headers }) + ) as unknown as typeof fetch + await expect( + requestHostRpcAdmission('subject-a', 'host-a', 'rpc-token', { fetchImpl }) + ).rejects.toMatchObject({ + status: 503, + body: { error: 'host_rpc_admission_unavailable' }, + }) + }) + + it('fails closed with the approved typed 503 for store failure or malformed response', async () => { + const unavailable = vi.fn(async () => + Response.json({ error: 'host_rpc_admission_unavailable' }, { status: 503 }) + ) as unknown as typeof fetch + await expect( + requestHostRpcAdmission('subject-a', 'host-a', 'rpc-token', { fetchImpl: unavailable }) + ).rejects.toMatchObject({ + status: 503, + body: { error: 'host_rpc_admission_unavailable' }, + }) + + const malformed = vi.fn(async () => + Response.json({ error: 'Too Many Requests', retryAfterSeconds: 0 }, { status: 429 }) + ) as unknown as typeof fetch + await expect( + requestHostRpcAdmission('subject-a', 'host-a', 'rpc-token', { fetchImpl: malformed }) + ).rejects.toMatchObject({ + status: 503, + body: { error: 'host_rpc_admission_unavailable' }, + }) + }) +}) diff --git a/rpc-proxy/src/__tests__/host-wake-route.test.ts b/rpc-proxy/src/__tests__/host-wake-route.test.ts index 8527cf66da..e810aa7dbe 100644 --- a/rpc-proxy/src/__tests__/host-wake-route.test.ts +++ b/rpc-proxy/src/__tests__/host-wake-route.test.ts @@ -35,6 +35,7 @@ const serviceMock = vi.hoisted(() => ({ const controlApiMock = vi.hoisted(() => ({ fetchUserAllowedServersFromControlApi: vi.fn(), fetchHostConnectionFromControlApi: vi.fn(), + requestHostRpcAdmission: async () => undefined, requestHostWakeFromControlApi: vi.fn(), })) diff --git a/rpc-proxy/src/__tests__/model-selection.test.ts b/rpc-proxy/src/__tests__/model-selection.test.ts index 52886365f0..d66cd5c484 100644 --- a/rpc-proxy/src/__tests__/model-selection.test.ts +++ b/rpc-proxy/src/__tests__/model-selection.test.ts @@ -7,6 +7,9 @@ const authTokenMock = vi.hoisted(() => ({ verifyRpcToken: vi.fn() })) const serviceMock = vi.hoisted(() => ({ resolveHostConnectionForUser: vi.fn(), })) +vi.mock('../services/hostRpcAdmission.js', () => ({ + admitLegacyHostRpcRequest: async () => true, +})) vi.mock('../authToken.js', () => authTokenMock) vi.mock('../services/mcpProxyService.js', () => serviceMock) @@ -175,7 +178,7 @@ describe('POST /rpc/hosts/:hostRef/model — set per-session model', () => { const res = await request(makeApp()) .post('/rpc/hosts/chatllm/model') .set('authorization', 'Bearer user-token') - .send({ chatId: 'c1', model: 'claude-haiku-4-5', expectedRevision: 2 }) + .send({ chatId: ' c1 ', model: ' claude-haiku-4-5 ', expectedRevision: 2 }) .expect(200) expect(res.body).toEqual(upstream) diff --git a/rpc-proxy/src/__tests__/progress-stream.test.ts b/rpc-proxy/src/__tests__/progress-stream.test.ts index bf10e582ba..ea014e594b 100644 --- a/rpc-proxy/src/__tests__/progress-stream.test.ts +++ b/rpc-proxy/src/__tests__/progress-stream.test.ts @@ -24,6 +24,9 @@ const serviceMock = vi.hoisted(() => ({ } }, })) +vi.mock('../services/hostRpcAdmission.js', () => ({ + admitLegacyHostRpcRequest: async () => true, +})) vi.mock('../authToken.js', () => authTokenMock) vi.mock('../services/mcpProxyService.js', () => serviceMock) diff --git a/rpc-proxy/src/__tests__/rpc-connectors-route.test.ts b/rpc-proxy/src/__tests__/rpc-connectors-route.test.ts index 0317bdbcae..42e90e5979 100644 --- a/rpc-proxy/src/__tests__/rpc-connectors-route.test.ts +++ b/rpc-proxy/src/__tests__/rpc-connectors-route.test.ts @@ -23,6 +23,7 @@ const controlApiMock = vi.hoisted(() => { ControlApiConnectorsRejectedError, fetchUserConnectorsFromControlApi: vi.fn(), fetchHostConnectionFromControlApi: vi.fn(), + requestHostRpcAdmission: async () => undefined, requestHostWakeFromControlApi: vi.fn(), } }) diff --git a/rpc-proxy/src/__tests__/sender-assignment.test.ts b/rpc-proxy/src/__tests__/sender-assignment.test.ts index 64f33c19f8..e305fa402d 100644 --- a/rpc-proxy/src/__tests__/sender-assignment.test.ts +++ b/rpc-proxy/src/__tests__/sender-assignment.test.ts @@ -12,6 +12,9 @@ const serviceMock = vi.hoisted(() => ({ resolveHostConnectionForUser: vi.fn(), forwardHostMessageToHost: vi.fn(), })) +vi.mock('../services/hostRpcAdmission.js', () => ({ + admitLegacyHostRpcRequest: async () => true, +})) vi.mock('../authToken.js', () => authTokenMock) vi.mock('../services/mcpProxyService.js', () => serviceMock) diff --git a/rpc-proxy/src/__tests__/session-rename-passthrough.test.ts b/rpc-proxy/src/__tests__/session-rename-passthrough.test.ts index 58460d37e6..72f996a905 100644 --- a/rpc-proxy/src/__tests__/session-rename-passthrough.test.ts +++ b/rpc-proxy/src/__tests__/session-rename-passthrough.test.ts @@ -21,6 +21,9 @@ const delegationMock = vi.hoisted(() => ({ const serviceMock = vi.hoisted(() => ({ resolveHostConnectionForUser: vi.fn(), })) +vi.mock('../services/hostRpcAdmission.js', () => ({ + admitLegacyHostRpcRequest: async () => true, +})) vi.mock('../authToken.js', () => authTokenMock) vi.mock('../userDelegationV2.js', () => delegationMock) @@ -154,7 +157,7 @@ describe('PATCH /rpc/hosts/:hostRef/sessions/:agent/:chatId/name — rename pass globalThis.fetch = originalFetch }) - it('forwards PATCH to mcp-host with :agent and :chatId in the path and body verbatim', async () => { + it('forwards the canonical validated title to mcp-host with :agent and :chatId in the path', async () => { const upstream = { ok: true } const fetchMock = vi.fn().mockResolvedValue({ status: 200, @@ -166,7 +169,7 @@ describe('PATCH /rpc/hosts/:hostRef/sessions/:agent/:chatId/name — rename pass const res = await request(makeApp()) .patch('/rpc/hosts/chatllm/sessions/chatllm/c1/name') .set('authorization', 'Bearer user-token') - .send({ title: 'Quarterly planning' }) + .send({ title: ' Quarterly\nplanning ' }) .expect(200) expect(res.body).toEqual(upstream) @@ -178,7 +181,7 @@ describe('PATCH /rpc/hosts/:hostRef/sessions/:agent/:chatId/name — rename pass expect((init as RequestInit).headers).toMatchObject(HOST_CONNECTION.headers) expect((init as RequestInit).headers).not.toHaveProperty('authorization') expect((init as RequestInit).headers).not.toHaveProperty('Authorization') - // Body reaches the upstream verbatim (re-serialized from the parsed JSON). + // The shared canonical validator normalizes the title before admission. expect((init as RequestInit).body).toBe(JSON.stringify({ title: 'Quarterly planning' })) // Bounded upstream timeout (copied from the session reads, unlike /model). expect((init as RequestInit).signal).toBeInstanceOf(AbortSignal) @@ -239,6 +242,26 @@ describe('PATCH /rpc/hosts/:hostRef/sessions/:agent/:chatId/name — rename pass expect(upstreamHeaders['x-clerum-edge-user-id']).toBeUndefined() }) + it('keeps malformed JSON ahead of v2 binding and W1 for a sibling Host', async () => { + delegationMock.tokenDeclaresV2.mockImplementation(token => token === 'v2-token') + const fetchMock = vi.fn() + globalThis.fetch = fetchMock as typeof fetch + + const orderedApp = express() + orderedApp.use(createRpcRouter()) + const response = await request(orderedApp) + .patch('/rpc/hosts/invalid_host/sessions/chatllm/c1/name') + .set('authorization', 'Bearer v2-token') + .set('content-type', 'application/json') + .send('{') + + expect(response.status).toBe(400) + expect(response.body).not.toMatchObject({ error: 'invalid_binding' }) + expect(fetchMock).not.toHaveBeenCalled() + expect(serviceMock.resolveHostConnectionForUser).not.toHaveBeenCalled() + expect(authTokenMock.verifyRpcToken).not.toHaveBeenCalled() + }) + it('returns 401 when no auth token is presented', async () => { const fetchMock = vi.fn() globalThis.fetch = fetchMock as unknown as typeof fetch diff --git a/rpc-proxy/src/__tests__/session-search-v2.test.ts b/rpc-proxy/src/__tests__/session-search-v2.test.ts index e8e38580c0..b1beb537c6 100644 --- a/rpc-proxy/src/__tests__/session-search-v2.test.ts +++ b/rpc-proxy/src/__tests__/session-search-v2.test.ts @@ -178,4 +178,17 @@ describe('GET /rpc/hosts/:hostRef/sessions/search v2 transport', () => { expect(fetchMock).not.toHaveBeenCalled() }) + + it('keeps v2 Host binding ahead of W1 without legacy fallback', async () => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock as typeof fetch + const response = await request(makeApp()) + .get('/rpc/hosts/invalid_host/sessions/search?q=budget') + .set('authorization', 'Bearer v2-token') + + expect(response.status).toBe(400) + expect(response.body).toMatchObject({ error: 'invalid_binding' }) + expect(fetchMock).not.toHaveBeenCalled() + expect(legacyAuthMock.verifyRpcToken).not.toHaveBeenCalled() + }) }) diff --git a/rpc-proxy/src/__tests__/sessions-passthrough.test.ts b/rpc-proxy/src/__tests__/sessions-passthrough.test.ts index 21ce230b38..cda1f8de95 100644 --- a/rpc-proxy/src/__tests__/sessions-passthrough.test.ts +++ b/rpc-proxy/src/__tests__/sessions-passthrough.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { encodeSessionsCursor, sessionsCursorScope } from '@clerum/action-context-contracts' import { createApp } from '../app.js' import { createRpcRouter } from '../routes/rpc.js' @@ -8,6 +9,9 @@ const authTokenMock = vi.hoisted(() => ({ verifyRpcToken: vi.fn() })) const serviceMock = vi.hoisted(() => ({ resolveHostConnectionForUser: vi.fn(), })) +vi.mock('../services/hostRpcAdmission.js', () => ({ + admitLegacyHostRpcRequest: async () => true, +})) vi.mock('../authToken.js', () => authTokenMock) vi.mock('../services/mcpProxyService.js', () => serviceMock) @@ -85,6 +89,11 @@ describe('GET /rpc/hosts/:hostRef/sessions — passthrough to mcp-host', () => { }) it('forwards only supported session pagination query parameters', async () => { + const cursor = encodeSessionsCursor( + '2026-04-22T00:00:00.000Z', + 'session-a', + sessionsCursorScope(VALID_CLAIMS.sub) + ) const fetchMock = vi.fn().mockResolvedValue({ status: 200, headers: new Headers({ 'content-type': 'application/json' }), @@ -93,12 +102,14 @@ describe('GET /rpc/hosts/:hostRef/sessions — passthrough to mcp-host', () => { globalThis.fetch = fetchMock as unknown as typeof fetch await request(makeApp()) - .get('/rpc/hosts/chatllm/sessions?limit=25&cursor=current&userId=other') + .get(`/rpc/hosts/chatllm/sessions?limit=25&cursor=${encodeURIComponent(cursor)}&userId=other`) .set('authorization', 'Bearer user-token') .expect(200) const [url] = fetchMock.mock.calls[0] - expect(String(url)).toBe('http://chatllm:8080/v1/runtime/sessions?limit=25&cursor=current') + expect(String(url)).toBe( + `http://chatllm:8080/v1/runtime/sessions?limit=25&cursor=${encodeURIComponent(cursor)}` + ) }) it('bounds and scopes session pagination before forwarding upstream', async () => { diff --git a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts index 5e6669fb29..46e46225ee 100644 --- a/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts +++ b/rpc-proxy/src/__tests__/wake-and-hold-route.test.ts @@ -35,6 +35,7 @@ const serviceMock = vi.hoisted(() => ({ const controlApiMock = vi.hoisted(() => ({ fetchUserAllowedServersFromControlApi: vi.fn(), fetchHostConnectionFromControlApi: vi.fn(), + requestHostRpcAdmission: async () => undefined, requestHostWakeFromControlApi: vi.fn(), })) diff --git a/rpc-proxy/src/__tests__/wake-route-coverage.test.ts b/rpc-proxy/src/__tests__/wake-route-coverage.test.ts index f8601e5bfa..15fb940386 100644 --- a/rpc-proxy/src/__tests__/wake-route-coverage.test.ts +++ b/rpc-proxy/src/__tests__/wake-route-coverage.test.ts @@ -40,6 +40,7 @@ const serviceMock = vi.hoisted(() => ({ const controlApiMock = vi.hoisted(() => ({ fetchUserAllowedServersFromControlApi: vi.fn(), fetchHostConnectionFromControlApi: vi.fn(), + requestHostRpcAdmission: async () => undefined, requestHostWakeFromControlApi: vi.fn(), })) diff --git a/rpc-proxy/src/actionAuthorityV2.test.ts b/rpc-proxy/src/actionAuthorityV2.test.ts index ac2813f8bb..d8de1fe877 100644 --- a/rpc-proxy/src/actionAuthorityV2.test.ts +++ b/rpc-proxy/src/actionAuthorityV2.test.ts @@ -316,6 +316,32 @@ describe('action authority checkpoint and cache isolation', () => { ).rejects.toMatchObject({ status: 503, code: 'host_message_admission_unavailable' }) }) + it('validates the distinct Host-RPC unavailable contract for non-message checkpoints', async () => { + const delegation = claims('direct', null) + const unavailable = vi.fn( + async () => + new Response(JSON.stringify({ error: 'host_rpc_admission_unavailable' }), { + status: 503, + }) + ) + await expect( + authorizeActionV2(delegation, bound, { fetchImpl: unavailable }) + ).rejects.toMatchObject({ + status: 503, + code: 'host_rpc_admission_unavailable', + }) + + const wrongOperationError = vi.fn( + async () => + new Response(JSON.stringify({ error: 'host_message_admission_unavailable' }), { + status: 503, + }) + ) + await expect( + authorizeActionV2(delegation, bound, { fetchImpl: wrongOperationError }) + ).rejects.toMatchObject({ status: 503, code: 'authority_unavailable' }) + }) + it('fails closed on response-status substitution', async () => { const delegation = claims('direct', null) const fetchImpl = vi.fn( diff --git a/rpc-proxy/src/actionAuthorityV2.ts b/rpc-proxy/src/actionAuthorityV2.ts index 050ed19503..5274034722 100644 --- a/rpc-proxy/src/actionAuthorityV2.ts +++ b/rpc-proxy/src/actionAuthorityV2.ts @@ -9,6 +9,7 @@ import { canonicalActionTargetJson, validateActionAuthorityCheckpointResponse, validateHostMessageAdmissionFailureResponse, + validateHostRpcAdmissionFailureResponse, } from '@clerum/action-context-contracts' import { config } from './config.js' import type { UserDelegationV2Claims } from './userDelegationV2.js' @@ -43,7 +44,8 @@ export class ActionAuthorityCheckpointError extends Error { | 'access_path_stale' | 'authority_unavailable' | 'Too Many Requests' - | 'host_message_admission_unavailable', + | 'host_message_admission_unavailable' + | 'host_rpc_admission_unavailable', readonly currentAuthorizationRevision?: string, readonly rateLimit?: Readonly<{ retryAfterSeconds: number @@ -222,7 +224,10 @@ export async function authorizeActionV2( } if (response.status === 429 || response.status === 503) { try { - const admissionFailure = validateHostMessageAdmissionFailureResponse(body) + const admissionFailure = + bound.operationId === 'chat.message.invoke' + ? validateHostMessageAdmissionFailureResponse(body) + : validateHostRpcAdmissionFailureResponse(body) if (response.status === 429 && admissionFailure.error === 'Too Many Requests') { const retryAfter = response.headers.get('Retry-After') const limit = response.headers.get('X-RateLimit-Limit') @@ -261,6 +266,9 @@ export async function authorizeActionV2( ) { throw new ActionAuthorityCheckpointError(503, 'host_message_admission_unavailable') } + if (response.status === 503 && admissionFailure.error === 'host_rpc_admission_unavailable') { + throw new ActionAuthorityCheckpointError(503, 'host_rpc_admission_unavailable') + } } catch (error) { if (error instanceof ActionAuthorityCheckpointError) throw error } diff --git a/rpc-proxy/src/hostRpcPreflight.ts b/rpc-proxy/src/hostRpcPreflight.ts new file mode 100644 index 0000000000..258a9a3621 --- /dev/null +++ b/rpc-proxy/src/hostRpcPreflight.ts @@ -0,0 +1,322 @@ +import type { NextFunction, Response } from 'express' +import { + decodeSessionsCursor, + sessionsCursorScope, + validateHostActivityLimit, + validateHostApprovalRequestId, + validateHostModelSelectionRequest, + validateSessionRenameTitle, +} from '@clerum/action-context-contracts' +import type { AuthedRequest } from './middleware/auth.js' + +const SESSION_LIMIT_CAP = 100 +const MESSAGE_LIMIT_CAP = 200 +const SESSION_SEARCH_LIMIT_CAP = 50 + +export type HostRpcPreflight = { + route: string + hostRef: string + agent?: string + chatId?: string + taskId?: string + query?: string + scope?: string + channel?: string + since?: string + limit?: number + beforeTurn?: number + afterTurn?: number + cursor?: string + body?: Record + validatedTitle?: string + approvalRequestId?: unknown +} + +type ParseResult = { value: HostRpcPreflight } | { status: number; body: unknown } + +function safePathSegment(value: string): boolean { + return ( + value.length > 0 && + value !== '.' && + value !== '..' && + value.length <= 500 && + !/[/\\\u0000-\u001f\u007f]/.test(value) + ) +} + +function safeAgentSegment(value: string): boolean { + return safePathSegment(value) && value.length <= 200 && !value.includes(':') +} + +function integerQuery(value: unknown): number | undefined | null { + if (value === undefined) return undefined + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? parsed : null +} + +function fail(error: string): ParseResult { + return { status: 400, body: { error } } +} + +function failBody(body: unknown): ParseResult { + return { status: 400, body } +} + +export function hostStreamBodyError(req: AuthedRequest): string | undefined { + if ( + Number(req.headers['content-length'] || 0) <= 0 && + req.headers['transfer-encoding'] === undefined + ) + return undefined + const route = String(req.route?.path || '') + if (route.endsWith('/status/stream')) + return 'Status stream is read-only and does not accept request bodies' + if (route.endsWith('/activity/stream')) + return 'Activity stream is read-only and does not accept request bodies' + if (route.endsWith('/progress/stream')) + return 'Progress stream is read-only and does not accept request bodies' + return undefined +} + +function parseRoute(req: AuthedRequest): ParseResult { + const route = `${req.method.toUpperCase()} ${String(req.route?.path || '')}` + const hostRef = String(req.params.hostRef || '').trim() + const base: HostRpcPreflight = { route, hostRef } + + if (route === 'POST /rpc/hosts/:hostRef/messages') { + if (!safePathSegment(hostRef)) return fail('Invalid hostRef') + const body = req.body as unknown + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return fail('Invalid host message request payload') + } + const message = body as Record + if (typeof message.content !== 'string' || !message.content.trim()) { + return fail('Invalid host message request payload') + } + if (message.attachments != null && !Array.isArray(message.attachments)) { + return failBody({ + error: 'invalid_attachments', + message: 'Attachments must be a list.', + }) + } + return { value: { ...base, body: message } } + } + + if (route === 'GET /rpc/hosts/:hostRef/sessions/search') { + const rawQuery = req.query.q + const rawScope = req.query.scope + const rawChannel = req.query.channel + const rawSince = req.query.since + const limit = integerQuery(req.query.limit) + if ( + !safePathSegment(hostRef) || + typeof rawQuery !== 'string' || + !rawQuery.trim() || + (rawScope !== undefined && typeof rawScope !== 'string') || + (rawChannel !== undefined && (typeof rawChannel !== 'string' || !rawChannel.trim())) || + (rawSince !== undefined && (typeof rawSince !== 'string' || !rawSince.trim())) || + limit === null || + (limit !== undefined && limit < 1) + ) + return fail('Invalid session search query') + return { + value: { + ...base, + query: rawQuery.trim(), + scope: rawScope as string | undefined, + channel: typeof rawChannel === 'string' ? rawChannel.trim() : undefined, + since: typeof rawSince === 'string' ? rawSince.trim() : undefined, + limit: limit === undefined ? undefined : Math.min(limit, SESSION_SEARCH_LIMIT_CAP), + }, + } + } + + if (route === 'GET /rpc/hosts/:hostRef/sessions') { + const rawAgent = req.query.agent + const agent = typeof rawAgent === 'string' ? rawAgent.trim() : undefined + const rawCursor = req.query.cursor + const cursor = typeof rawCursor === 'string' ? rawCursor : '' + const limit = integerQuery(req.query.limit) + if ( + !safePathSegment(hostRef) || + (rawAgent !== undefined && typeof rawAgent !== 'string') || + (rawCursor !== undefined && typeof rawCursor !== 'string') || + (rawAgent !== undefined && (!agent || !safeAgentSegment(agent))) || + (rawCursor !== undefined && !cursor) || + cursor.length > 2048 || + limit === null || + (limit !== undefined && limit < 1) + ) + return fail('Invalid session pagination query') + if ( + rawCursor !== undefined && + !decodeSessionsCursor(cursor, sessionsCursorScope(req.auth!.sub, agent)) + ) + return fail('Invalid sessions cursor') + return { + value: { + ...base, + agent, + cursor, + limit: limit === undefined ? undefined : Math.min(limit, SESSION_LIMIT_CAP), + }, + } + } + + if (route === 'GET /rpc/hosts/:hostRef/sessions/:agent/:chatId/messages') { + const agent = String(req.params.agent || '').trim() + const chatId = String(req.params.chatId || '').trim() + if (!safePathSegment(hostRef) || !safeAgentSegment(agent) || !safePathSegment(chatId)) + return fail('Invalid hostRef, agent, or chatId') + const limit = integerQuery(req.query.limit) + const beforeTurn = integerQuery(req.query.beforeTurn) + const afterTurn = integerQuery(req.query.afterTurn) + const invalidShape = ['limit', 'beforeTurn', 'afterTurn'].some( + key => req.query[key] !== undefined && typeof req.query[key] !== 'string' + ) + if ( + invalidShape || + limit === null || + (limit !== undefined && limit < 1) || + beforeTurn === null || + (beforeTurn !== undefined && beforeTurn < 1) || + afterTurn === null || + (afterTurn !== undefined && afterTurn < 0) || + (beforeTurn !== undefined && afterTurn !== undefined) + ) + return fail('Invalid session messages pagination query') + return { + value: { + ...base, + agent, + chatId, + limit: limit === undefined ? undefined : Math.min(limit, MESSAGE_LIMIT_CAP), + beforeTurn, + afterTurn, + }, + } + } + + if (route === 'GET /rpc/hosts/:hostRef/sessions/:agent/:chatId/context-breakdown') { + const agent = String(req.params.agent || '').trim() + const chatId = String(req.params.chatId || '').trim() + if (!safePathSegment(hostRef) || !safeAgentSegment(agent) || !safePathSegment(chatId)) + return fail('Invalid hostRef, agent, or chatId') + return { value: { ...base, agent, chatId } } + } + + if (route === 'PATCH /rpc/hosts/:hostRef/sessions/:agent/:chatId/name') { + const agent = String(req.params.agent || '').trim() + const chatId = String(req.params.chatId || '').trim() + if (!safePathSegment(hostRef) || !safeAgentSegment(agent) || !safePathSegment(chatId)) + return fail('Invalid hostRef, agent, or chatId') + const body = req.body as unknown + const titleCandidate = + body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record).title + : undefined + const rawTitle = typeof titleCandidate === 'string' ? titleCandidate : '' + const titleValidation = validateSessionRenameTitle(rawTitle) + if (!titleValidation.ok) return fail(titleValidation.error) + return { value: { ...base, agent, chatId, validatedTitle: titleValidation.title } } + } + + if (route === 'POST /rpc/hosts/:hostRef/model') { + const body = req.body as unknown + if (!hostRef) return fail('hostRef is required') + if (!body || typeof body !== 'object' || Array.isArray(body)) + return fail('Invalid set-model request payload') + const modelValidation = validateHostModelSelectionRequest(body) + if (!modelValidation.ok) return fail(modelValidation.error) + return { + value: { + ...base, + body: { + ...(body as Record), + chatId: modelValidation.chatId, + model: modelValidation.model, + }, + }, + } + } + + if ( + /^\w+ \/rpc\/hosts\/:hostRef\/tasks\/:taskId\/(result|cancel|progress\/stream)$/.test(route) + ) { + const taskId = String(req.params.taskId || '').trim() + if (!hostRef) return fail('hostRef is required') + const progress = route.endsWith('/progress/stream') + if (progress && (!safePathSegment(hostRef) || /[*%]/.test(hostRef))) + return fail('Invalid hostRef') + if ( + progress + ? !taskId || taskId.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(taskId) + : !taskId || !/^[a-zA-Z0-9_-]+$/.test(taskId) + ) { + return fail( + progress ? 'taskId is required and must be alphanumeric (max 128 chars)' : 'Invalid taskId' + ) + } + if (progress) { + const bodyError = hostStreamBodyError(req) + if (bodyError) return fail(bodyError) + } + return { value: { ...base, taskId } } + } + + if (route.includes('/approvals/')) { + if (!hostRef) return fail('hostRef is required') + const body = req.body as Record | null | undefined + const validation = validateHostApprovalRequestId(body?.toolCallId || body?.requestId) + if (!validation.ok) return fail(validation.error) + return { value: { ...base, approvalRequestId: validation.requestId } } + } + if (route.endsWith('/wake')) { + if (!safePathSegment(hostRef)) return fail('Invalid hostRef') + return { value: base } + } + if (route.endsWith('/models')) { + if (!hostRef) return fail('hostRef is required') + const chatId = typeof req.query.chatId === 'string' ? req.query.chatId.trim() : '' + return { value: { ...base, chatId } } + } + if ( + route.endsWith('/activity') || + route.endsWith('/status') || + route.endsWith('/health') || + route.endsWith('/status/stream') || + route.endsWith('/activity/stream') + ) { + if (!hostRef) return fail('hostRef is required') + if (route === 'GET /rpc/hosts/:hostRef/activity') { + const parsedLimit = validateHostActivityLimit(req.query.limit) + if (!parsedLimit.ok) return fail(parsedLimit.error) + return { value: { ...base, limit: parsedLimit.limit } } + } + if ( + (route.endsWith('/status/stream') || route.endsWith('/activity/stream')) && + /[*%]/.test(hostRef) + ) + return fail('hostRef is required') + const bodyError = hostStreamBodyError(req) + if (bodyError) return fail(bodyError) + return { value: base } + } + throw new Error(`Spec 65 Host-RPC route has no canonical preflight: ${route}`) +} + +export function hostRpcRoutePreflight(req: AuthedRequest, res: Response, next: NextFunction): void { + const result = parseRoute(req) + if ('status' in result) { + res.status(result.status).json(result.body) + return + } + req.hostRpcPreflight = result.value + next() +} + +export function getHostRpcPreflight(req: AuthedRequest): T { + if (!req.hostRpcPreflight) throw new Error('Host-RPC route preflight was not run') + return req.hostRpcPreflight as T +} diff --git a/rpc-proxy/src/middleware/auth.ts b/rpc-proxy/src/middleware/auth.ts index 0781abcaee..e6daa15f2c 100644 --- a/rpc-proxy/src/middleware/auth.ts +++ b/rpc-proxy/src/middleware/auth.ts @@ -1,19 +1,36 @@ import { NextFunction, Request, Response } from 'express' -import type { AuthorizedActionV2, HostMessageAdmissionRetryContext } from '../actionAuthorityV2.js' +import type { + AuthorizedActionV2, + BoundActionV2, + HostMessageAdmissionRetryContext, +} from '../actionAuthorityV2.js' import { verifyRpcToken } from '../authToken.js' import { config } from '../config.js' -import { authorizeBoundRequestV2 } from '../routeActionBindingV2.js' +import { hostRpcRoutePreflight } from '../hostRpcPreflight.js' +import type { HostRpcPreflight } from '../hostRpcPreflight.js' +import { + authorizeBoundRequestV2, + authorizePreparedBoundRequestV2, + bindBoundRequestV2, +} from '../routeActionBindingV2.js' import { RpcAccessClaims, RpcScope } from '../types.js' import { type UserDelegationV2Claims, tokenDeclaresV2, verifyUserDelegationV2, } from '../userDelegationV2.js' +import { validateHostRef } from './hostRefValidation.js' +import { jsonBody } from './jsonBody.js' export type AuthedRequest = Request & { auth?: RpcAccessClaims userDelegationV2?: UserDelegationV2Claims + /** Locally bound action retained only while a staged Host-RPC preflight runs. */ + boundActionV2?: BoundActionV2 + /** Canonically parsed Spec 65 request, retained across remote checkpoint. */ + hostRpcPreflight?: HostRpcPreflight authorizedActionV2?: AuthorizedActionV2 + deferHostV2Checkpoint?: boolean /** Per-inbound-request receipt; never pass this into shared wake coordination. */ hostMessageAdmissionRetryContext?: HostMessageAdmissionRetryContext } @@ -72,9 +89,16 @@ export function requireRpcAuth(req: AuthedRequest, res: Response, next: NextFunc next() } -export function requireScope(scope: RpcScope) { +export function requireScope(scope: RpcScope, options: { deferHostV2Checkpoint?: boolean } = {}) { return (req: AuthedRequest, res: Response, next: NextFunction): void => { if (req.userDelegationV2) { + if (options.deferHostV2Checkpoint) { + // Spec 61 Host-ref routes complete their exact local binding and W1 + // inside the route's established validation point before checkpoint I/O. + req.deferHostV2Checkpoint = true + next() + return + } void authorizeBoundRequestV2(req, res, next) return } @@ -87,6 +111,115 @@ export function requireScope(scope: RpcScope) { } } +/** Local-only scope/binding stage for the Spec 65 preflight routes. */ +export function bindHostRpcScope(scope: RpcScope) { + return (req: AuthedRequest, res: Response, next: NextFunction): void => { + if (req.userDelegationV2) { + if (bindBoundRequestV2(req, res)) next() + return + } + const auth = req.auth + if (!auth || !auth.scopes.includes(scope)) { + res.status(403).json({ error: 'Forbidden: missing scope' }) + return + } + next() + } +} + +/** Run the remote action checkpoint after canonical Spec 65 route preflight. */ +export function checkpointBoundHostRpcAction( + req: AuthedRequest, + res: Response, + next: NextFunction +): void { + if (!req.userDelegationV2) return next() + void authorizePreparedBoundRequestV2(req, res, next) +} + +/** Spec 65 routes only: auth was already run, then local scope/binding, preflight, checkpoint. */ +export function requireHostRpcPreflightScope(scope: RpcScope) { + const bindScope = bindHostRpcScope(scope) + return (req: AuthedRequest, res: Response, next: NextFunction): void => { + bindScope(req, res, error => { + if (error) return next(error) + runHostRpcPreflightCheckpoint(req, res, next) + }) + } +} + +/** + * Preserve legacy scope-before-body precedence while v2 keeps parser-before-binding precedence. + * Both variants run the same canonical preflight, W1, and checkpoint stages afterward. + */ +export function requireHostRpcJsonBodyPreflightScope(scope: RpcScope) { + const bindScope = bindHostRpcScope(scope) + return (req: AuthedRequest, res: Response, next: NextFunction): void => { + const bindThenPreflight = (): void => { + bindScope(req, res, error => { + if (error) return next(error) + runHostRpcPreflightCheckpoint(req, res, next) + }) + } + if (req.userDelegationV2) { + jsonBody(req, res, error => { + if (error) return next(error) + bindThenPreflight() + }) + return + } + bindScope(req, res, error => { + if (error) return next(error) + jsonBody(req, res, parseError => { + if (parseError) return next(parseError) + runHostRpcPreflightCheckpoint(req, res, next) + }) + }) + } +} + +/** W1 path for Spec 61 routes outside the Spec 65 admission class (artifacts). */ +export function requireHostRefCheckpointScope(scope: RpcScope) { + const bindScope = bindHostRpcScope(scope) + return (req: AuthedRequest, res: Response, next: NextFunction): void => { + bindScope(req, res, error => { + if (error) return next(error) + if (!validateHostRef(req, res)) return + checkpointBoundHostRpcAction(req, res, next) + }) + } +} + +/** W1 after a route-specific deterministic validator, before the remote checkpoint. */ +export function runHostRefCheckpoint(req: AuthedRequest, res: Response, next: NextFunction): void { + if (!validateHostRef(req, res)) return + checkpointBoundHostRpcAction(req, res, next) +} + +export function runHostRpcPreflightCheckpoint( + req: AuthedRequest, + res: Response, + next: NextFunction +): void { + hostRpcRoutePreflight(req, res, error => { + if (error) return next(error) + if (!validateHostRef(req, res)) return + checkpointBoundHostRpcAction(req, res, next) + }) +} + +export function requireV2SessionSearch( + req: AuthedRequest, + res: Response, + next: NextFunction +): void { + if (!req.userDelegationV2) { + res.status(403).json({ error: 'User delegation v2 required for session search' }) + return + } + next() +} + /** * Public rpc-proxy requests cannot supply trusted edge authorization context. * Strip the entire namespace before any route can inspect or forward it. diff --git a/rpc-proxy/src/middleware/hostRefValidation.ts b/rpc-proxy/src/middleware/hostRefValidation.ts new file mode 100644 index 0000000000..a7e7999036 --- /dev/null +++ b/rpc-proxy/src/middleware/hostRefValidation.ts @@ -0,0 +1,38 @@ +import type { Request, RequestHandler, Response } from 'express' +import { + authorizeDeferredHostRouteActionV2, + bindDeferredHostRouteActionV2, +} from '../routeActionBindingV2.js' +import type { AuthedRequest } from './auth.js' + +// Mirrors the authoritative Control API Host-create metadata.name contract. +// Express has already decoded the captured segment; do not normalize it. +export const HOST_REF_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ + +export function validateHostRef(req: Request, res: Response): boolean { + const hostRef = req.params.hostRef + if (typeof hostRef !== 'string' || !HOST_REF_RE.test(hostRef)) { + res.status(400).json({ error: 'Invalid hostRef' }) + return false + } + return true +} + +/** Completes the approved auth/local-bind → W1 → remote-checkpoint sequence. */ +export async function validateHostRefAndAuthorize( + req: AuthedRequest, + res: Response +): Promise { + if (req.deferHostV2Checkpoint && !bindDeferredHostRouteActionV2(req, res)) return false + if (!validateHostRef(req, res)) return false + if (req.deferHostV2Checkpoint) { + return authorizeDeferredHostRouteActionV2(req, res) + } + return true +} + +export const requireValidHostRef: RequestHandler = (req, res, next) => { + void validateHostRefAndAuthorize(req as AuthedRequest, res).then(valid => { + if (valid) next() + }) +} diff --git a/rpc-proxy/src/routeActionBindingV2.ts b/rpc-proxy/src/routeActionBindingV2.ts index b56a66a34e..51067f0334 100644 --- a/rpc-proxy/src/routeActionBindingV2.ts +++ b/rpc-proxy/src/routeActionBindingV2.ts @@ -384,53 +384,145 @@ export async function authorizeBoundRequestV2( ) => Promise } = {} ): Promise { + if (!bindBoundRequestV2(req, res)) return + await authorizePreparedBoundRequestV2(req, res, next, options) +} + +/** Perform the existing exact local binding phase without contacting Control API. */ +export function bindBoundRequestV2(req: AuthedRequest, res: Response): boolean { const claims = req.userDelegationV2 if (!claims) { res.status(401).json({ error: 'Unauthorized' }) + return false + } + try { + req.boundActionV2 = bindRouteActionV2(req, claims) + } catch (error) { + if (error instanceof RouteActionBindingError) { + res.status(400).json({ error: 'invalid_binding' }) + return false + } + res.status(503).json({ error: 'authority_unavailable' }) + return false + } + return true +} + +/** Execute the existing remote checkpoint for a locally bound action. */ +export async function authorizePreparedBoundRequestV2( + req: AuthedRequest, + res: Response, + next: NextFunction, + options: { + authorize?: ( + claims: UserDelegationV2Claims, + bound: BoundActionV2, + options?: { + hostMessageAdmission?: HostMessageAdmissionCheckpointContext + onHostMessageAdmissionReceipt?: (receipt: string) => void + } + ) => Promise + } = {} +): Promise { + const claims = req.userDelegationV2 + const bound = req.boundActionV2 + if (!claims || !bound) { + res.status(400).json({ error: 'invalid_binding' }) return } + req.boundActionV2 = bound + if (bound.operationId === 'chat.message.invoke' && !req.hostMessageAdmissionRetryContext) { + req.hostMessageAdmissionRetryContext = { + sendNonce: randomBytes(32).toString('base64url'), + delegationExpiresAt: claims.exp, + } + } + const authorized = await authorizeBoundActionV2(req, res, options.authorize) + if (authorized) next() +} + +/** Performs only the pure exact local route/delegation binding for a staged Host-ref route. */ +export function bindDeferredHostRouteActionV2(req: AuthedRequest, res: Response): boolean { + const claims = req.userDelegationV2 + if (!claims) return true + if (req.boundActionV2) return true + let bound: BoundActionV2 try { + if (!req.deferHostV2Checkpoint) throw new RouteActionBindingError('invalid_binding') bound = bindRouteActionV2(req, claims) } catch (error) { if (error instanceof RouteActionBindingError) { res.status(400).json({ error: 'invalid_binding' }) - return + } else { + res.status(503).json({ error: 'authority_unavailable' }) + } + return false + } + + req.boundActionV2 = bound + return true +} + +/** Runs the existing remote checkpoint only after the route's local gates pass. */ +export async function authorizeDeferredHostRouteActionV2( + req: AuthedRequest, + res: Response +): Promise { + if (!req.userDelegationV2) return true + if (!req.boundActionV2 && !bindDeferredHostRouteActionV2(req, res)) return false + return authorizeBoundActionV2(req, res) +} + +async function authorizeBoundActionV2( + req: AuthedRequest, + res: Response, + authorize: ( + claims: UserDelegationV2Claims, + bound: BoundActionV2, + options?: { + hostMessageAdmission?: HostMessageAdmissionCheckpointContext + onHostMessageAdmissionReceipt?: (receipt: string) => void + } + ) => Promise = authorizeActionV2 +): Promise { + const claims = req.userDelegationV2 + const bound = req.boundActionV2 + if (!claims || !bound) { + res.status(401).json({ error: 'Unauthorized' }) + return false + } + if (bound.operationId === 'chat.message.invoke' && !req.hostMessageAdmissionRetryContext) { + req.hostMessageAdmissionRetryContext = { + sendNonce: randomBytes(32).toString('base64url'), + delegationExpiresAt: claims.exp, } - res.status(503).json({ error: 'authority_unavailable' }) - return } - const hostMessageAdmission = - bound.operationId === 'chat.message.invoke' - ? { - sendNonce: randomBytes(32).toString('base64url'), - delegationExpiresAt: claims.exp, - } - : undefined - if (hostMessageAdmission) req.hostMessageAdmissionRetryContext = hostMessageAdmission + const admissionContext = req.hostMessageAdmissionRetryContext try { - req.authorizedActionV2 = await (options.authorize ?? authorizeActionV2)( + req.authorizedActionV2 = await authorize( claims, bound, - hostMessageAdmission + admissionContext ? { - hostMessageAdmission, + hostMessageAdmission: admissionContext, onHostMessageAdmissionReceipt: receipt => { req.hostMessageAdmissionRetryContext = Object.freeze({ - ...hostMessageAdmission, + ...admissionContext, receipt, }) }, } : undefined ) - next() + return true } catch (error) { if (error instanceof ActionAuthorityCheckpointError) { sendCheckpointError(res, error) - return + } else { + res.status(503).json({ error: 'authority_unavailable' }) } - res.status(503).json({ error: 'authority_unavailable' }) + return false } } diff --git a/rpc-proxy/src/routes/rpc.ts b/rpc-proxy/src/routes/rpc.ts index a54518f9ce..0b99ef7d9f 100644 --- a/rpc-proxy/src/routes/rpc.ts +++ b/rpc-proxy/src/routes/rpc.ts @@ -8,11 +8,19 @@ import { authorizeActionV2, } from '../actionAuthorityV2.js' import { config } from '../config.js' +import { getHostRpcPreflight } from '../hostRpcPreflight.js' import { type AuthedRequest, + bindHostRpcScope, extractAuthToken, + requireHostRefCheckpointScope, + requireHostRpcJsonBodyPreflightScope, + requireHostRpcPreflightScope, requireRpcAuth, requireScope, + requireV2SessionSearch, + runHostRefCheckpoint, + runHostRpcPreflightCheckpoint, } from '../middleware/auth.js' import { chatJsonBody } from '../middleware/chatJsonBody.js' import { jsonBody } from '../middleware/jsonBody.js' @@ -25,6 +33,7 @@ import { fetchUserConnectorsFromControlApi, requestHostWakeFromControlApi, } from '../services/controlApiRestService.js' +import { admitLegacyHostRpcRequest } from '../services/hostRpcAdmission.js' import { type HostRuntimeMessageRequest, forwardCancelToHost, @@ -50,12 +59,6 @@ import type { ResolvedServerConnection } from '../types.js' const RFC1123_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ -// Edge caps on the client-supplied page size before forwarding upstream. The -// host enforces its own identical caps independently; these are the proxy's -// own copy (the two services share no package, so each names its own). -const SESSIONS_LIMIT_CAP = 100 -const MESSAGES_LIMIT_CAP = 200 -const SESSION_SEARCH_LIMIT_CAP = 50 const HOST_ARTIFACT_READ_LIMIT_PER_MIN = 30 type ArtifactReadRequest = AuthedRequest & { @@ -72,17 +75,8 @@ function isSafeUpstreamPathSegment(value: string): boolean { ) } -function isSafeUpstreamAgentSegment(value: string): boolean { - return isSafeUpstreamPathSegment(value) && value.length <= 200 && !value.includes(':') -} - -function parseUnsignedIntegerQuery(value: unknown): number | undefined | null { - if (value === undefined) return undefined - if (typeof value !== 'string' || !/^\d+$/.test(value)) return null - const parsed = Number(value) - return Number.isSafeInteger(parsed) ? parsed : null -} - +// Keep legacy denial precedence (scope before body parsing), while v2 route +// binding must see parsed body data before its remote Control API checkpoint. /** * Pre-wake host error mapping: fetch timeout → 504, everything else → 502. * @@ -466,7 +460,8 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/messages', requireRpcAuth, chatJsonBody, - requireScope('host:message:invoke'), + bindHostRpcScope('host:message:invoke'), + runHostRpcPreflightCheckpoint, async (req: AuthedRequest, res) => { // Host runtime write path (REST-oriented): // - separate from read-only status stream @@ -474,21 +469,8 @@ export function createRpcRouter(): Router { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!isSafeUpstreamPathSegment(hostRef)) { - res.status(400).json({ error: 'Invalid hostRef' }) - return - } - - const body = req.body as HostRuntimeMessageRequest - if (!body || typeof body !== 'object' || Array.isArray(body)) { - res.status(400).json({ error: 'Invalid host message request payload' }) - return - } - if (typeof body.content !== 'string' || !body.content.trim()) { - res.status(400).json({ error: 'Invalid host message request payload' }) - return - } + const { hostRef, body: preflightBody } = getHostRpcPreflight(req) + const body = preflightBody as HostRuntimeMessageRequest // Identity invariant: rpc-proxy is the sole authority on the Desktop // RPC envelope. Ignore client-supplied channel, sender, host, and @@ -533,12 +515,6 @@ export function createRpcRouter(): Router { requestId, origin: 'direct_chat', }) - if (body.attachments != null && !Array.isArray(body.attachments)) { - res - .status(400) - .json({ error: 'invalid_attachments', message: 'Attachments must be a list.' }) - return - } const forwardedBody: HostRuntimeMessageRequest = { content: body.content, channelType: 'rpc', @@ -677,16 +653,14 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/wake', requireRpcAuth, jsonBody, - requireScope('host:wake:write'), + requireHostRpcPreflightScope('host:wake:write'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!isSafeUpstreamPathSegment(hostRef)) { - res.status(400).json({ error: 'Invalid hostRef' }) - return - } + const { hostRef } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, @@ -773,16 +747,13 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/approvals/approve', requireRpcAuth, jsonBody, - requireScope('host:approval:write'), + requireHostRpcPreflightScope('host:approval:write'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } + const { hostRef, approvalRequestId } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -801,7 +772,7 @@ export function createRpcRouter(): Router { const upstreamBody = { userId: auth.sub, ...(typeof parsed.taskId === 'string' ? { taskId: parsed.taskId } : {}), - requestId: parsed.toolCallId || parsed.requestId, + requestId: approvalRequestId, alwaysApprove: parsed.alwaysApprove || false, } // Wake-eligible finite operation (§11.4): the route scope stays @@ -845,16 +816,13 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/approvals/deny', requireRpcAuth, jsonBody, - requireScope('host:approval:write'), + requireHostRpcPreflightScope('host:approval:write'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } + const { hostRef, approvalRequestId } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -873,7 +841,7 @@ export function createRpcRouter(): Router { const upstreamBody = { userId: auth.sub, ...(typeof parsed.taskId === 'string' ? { taskId: parsed.taskId } : {}), - requestId: parsed.toolCallId || parsed.requestId, + requestId: approvalRequestId, } // Wake-eligible finite operation (§11.4): scope stays host:approval:write. const attempt = async () => { @@ -914,7 +882,9 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/sessions/search', requireRpcAuth, - requireScope('host:session:read'), + bindHostRpcScope('host:session:read'), + requireV2SessionSearch, + runHostRpcPreflightCheckpoint, async (req: AuthedRequest, res, next) => { try { if (!req.userDelegationV2 || !req.authorizedActionV2) { @@ -923,25 +893,16 @@ export function createRpcRouter(): Router { } const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - const rawQuery = req.query.q - const rawScope = req.query.scope - const rawChannel = req.query.channel - const rawSince = req.query.since - const rawLimit = parseUnsignedIntegerQuery(req.query.limit) - if ( - !isSafeUpstreamPathSegment(hostRef) || - typeof rawQuery !== 'string' || - !rawQuery.trim() || - (rawScope !== undefined && typeof rawScope !== 'string') || - (rawChannel !== undefined && (typeof rawChannel !== 'string' || !rawChannel.trim())) || - (rawSince !== undefined && (typeof rawSince !== 'string' || !rawSince.trim())) || - rawLimit === null || - (rawLimit !== undefined && rawLimit < 1) - ) { - res.status(400).json({ error: 'Invalid session search query' }) - return - } + const { + hostRef, + query: rawQuery, + scope: rawScope, + channel: rawChannel, + since: rawSince, + limit: rawLimit, + } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, @@ -954,7 +915,7 @@ export function createRpcRouter(): Router { return } const upstreamUrl = new URL(`${host.url.replace(/\/+$/, '')}/v1/runtime/sessions/search`) - upstreamUrl.searchParams.set('q', rawQuery.trim()) + upstreamUrl.searchParams.set('q', rawQuery!) upstreamUrl.searchParams.set( 'scope', rawScope === 'all_channels' ? 'all_channels' : 'this_channel' @@ -964,10 +925,7 @@ export function createRpcRouter(): Router { } if (typeof rawSince === 'string') upstreamUrl.searchParams.set('since', rawSince.trim()) if (rawLimit !== undefined) { - upstreamUrl.searchParams.set( - 'limit', - String(Math.min(rawLimit, SESSION_SEARCH_LIMIT_CAP)) - ) + upstreamUrl.searchParams.set('limit', String(rawLimit)) } const forwardSearch = async () => { @@ -1009,35 +967,18 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/sessions', requireRpcAuth, - requireScope('host:session:read'), + requireHostRpcPreflightScope('host:session:read'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!isSafeUpstreamPathSegment(hostRef)) { - res.status(400).json({ error: 'Invalid hostRef' }) - return - } - const rawSessionAgent = req.query.agent - const sessionAgent = - typeof rawSessionAgent === 'string' ? rawSessionAgent.trim() : undefined - const rawSessionCursor = req.query.cursor - const sessionCursor = typeof rawSessionCursor === 'string' ? rawSessionCursor : '' - const sessionLimit = parseUnsignedIntegerQuery(req.query.limit) - if ( - (rawSessionAgent !== undefined && typeof rawSessionAgent !== 'string') || - (rawSessionCursor !== undefined && typeof rawSessionCursor !== 'string') || - (rawSessionAgent !== undefined && - (!sessionAgent || !isSafeUpstreamAgentSegment(sessionAgent))) || - (rawSessionCursor !== undefined && !sessionCursor) || - sessionCursor.length > 2048 || - sessionLimit === null || - (sessionLimit !== undefined && sessionLimit < 1) - ) { - res.status(400).json({ error: 'Invalid session pagination query' }) - return - } + const { + hostRef, + agent: sessionAgent, + cursor: sessionCursor, + limit: sessionLimit, + } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -1052,7 +993,7 @@ export function createRpcRouter(): Router { const upstreamUrl = new URL(`${baseUrl}/v1/runtime/sessions`) if (sessionAgent) upstreamUrl.searchParams.set('agent', sessionAgent) if (sessionLimit !== undefined) { - upstreamUrl.searchParams.set('limit', String(Math.min(sessionLimit, SESSIONS_LIMIT_CAP))) + upstreamUrl.searchParams.set('limit', String(sessionLimit)) } if (sessionCursor) upstreamUrl.searchParams.set('cursor', sessionCursor) console.info(`[RPC_PROXY] user=${auth.sub} host=${hostRef} method=list-sessions`) @@ -1105,41 +1046,20 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/sessions/:agent/:chatId/messages', requireRpcAuth, - requireScope('host:session:read'), + requireHostRpcPreflightScope('host:session:read'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - const agent = String(req.params.agent || '').trim() - const chatId = String(req.params.chatId || '').trim() - if ( - !isSafeUpstreamPathSegment(hostRef) || - !isSafeUpstreamAgentSegment(agent) || - !isSafeUpstreamPathSegment(chatId) - ) { - res.status(400).json({ error: 'Invalid hostRef, agent, or chatId' }) - return - } - const rawLimit = parseUnsignedIntegerQuery(req.query.limit) - const beforeTurn = parseUnsignedIntegerQuery(req.query.beforeTurn) - const afterTurn = parseUnsignedIntegerQuery(req.query.afterTurn) - const invalidQueryShape = ['limit', 'beforeTurn', 'afterTurn'].some( - key => req.query[key] !== undefined && typeof req.query[key] !== 'string' - ) - if ( - invalidQueryShape || - rawLimit === null || - (rawLimit !== undefined && rawLimit < 1) || - beforeTurn === null || - (beforeTurn !== undefined && beforeTurn < 1) || - afterTurn === null || - (afterTurn !== undefined && afterTurn < 0) || - (beforeTurn !== undefined && afterTurn !== undefined) - ) { - res.status(400).json({ error: 'Invalid session messages pagination query' }) - return - } + const { + hostRef, + agent, + chatId, + limit: rawLimit, + beforeTurn, + afterTurn, + } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -1152,10 +1072,10 @@ export function createRpcRouter(): Router { } const baseUrl = host.url.replace(/\/+$/, '') const upstreamUrl = new URL( - `${baseUrl}/v1/runtime/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(chatId)}/messages` + `${baseUrl}/v1/runtime/sessions/${encodeURIComponent(agent!)}/${encodeURIComponent(chatId!)}/messages` ) if (rawLimit !== undefined) { - upstreamUrl.searchParams.set('limit', String(Math.min(rawLimit, MESSAGES_LIMIT_CAP))) + upstreamUrl.searchParams.set('limit', String(rawLimit)) } if (beforeTurn !== undefined) { upstreamUrl.searchParams.set('beforeTurn', String(beforeTurn)) @@ -1211,22 +1131,13 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/sessions/:agent/:chatId/context-breakdown', requireRpcAuth, - requireScope('host:session:read'), + requireHostRpcPreflightScope('host:session:read'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - const agent = String(req.params.agent || '').trim() - const chatId = String(req.params.chatId || '').trim() - if ( - !isSafeUpstreamPathSegment(hostRef) || - !isSafeUpstreamAgentSegment(agent) || - !isSafeUpstreamPathSegment(chatId) - ) { - res.status(400).json({ error: 'Invalid hostRef, agent, or chatId' }) - return - } + const { hostRef, agent, chatId } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -1244,7 +1155,7 @@ export function createRpcRouter(): Router { // Wake-eligible finite operation (§11.4): scope stays host:session:read. const attempt = async () => { const response = await fetch( - `${baseUrl}/v1/runtime/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(chatId)}/context-breakdown`, + `${baseUrl}/v1/runtime/sessions/${encodeURIComponent(agent!)}/${encodeURIComponent(chatId!)}/context-breakdown`, { method: 'GET', headers: { ...host.headers }, @@ -1283,10 +1194,9 @@ export function createRpcRouter(): Router { // Rename a session — write path, scoped to host:session:write (a distinct // scope from host:session:read, so a read/navigation token can never rename). // Passthrough to mcp-host PATCH /v1/runtime/sessions/:agent/:chatId/name: the - // request body (the new title) is forwarded verbatim, and mcp-host owns title - // validation, ownership, and the anti-enumeration 404, so its 400/403/404/200 - // pass through unchanged. Title content is NOT validated here (only the path - // segments); the raw title value is never logged. + // request body (the new title) uses the shared pure validator during + // preflight; mcp-host enforces the same validator at its independent runtime + // boundary. Ownership and anti-enumeration remain Host-owned. // // Deliberately NOT wake-eligible: the Desktop App renames optimistically // (local-first) and reconciles on the next listSessions poll, so a suspended @@ -1295,23 +1205,13 @@ export function createRpcRouter(): Router { router.patch( '/rpc/hosts/:hostRef/sessions/:agent/:chatId/name', requireRpcAuth, - requireScope('host:session:write'), - jsonBody, + requireHostRpcJsonBodyPreflightScope('host:session:write'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - const agent = String(req.params.agent || '').trim() - const chatId = String(req.params.chatId || '').trim() - if ( - !isSafeUpstreamPathSegment(hostRef) || - !isSafeUpstreamAgentSegment(agent) || - !isSafeUpstreamPathSegment(chatId) - ) { - res.status(400).json({ error: 'Invalid hostRef, agent, or chatId' }) - return - } + const { hostRef, agent, chatId, validatedTitle } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -1329,8 +1229,8 @@ export function createRpcRouter(): Router { // logged value can never forge an extra log line. Never log the raw title // (spec 15 §5): titles are user content. const logHost = hostRef.replace(/[\r\n]/g, '') - const logAgent = agent.replace(/[\r\n]/g, '') - const logChatId = chatId.replace(/[\r\n]/g, '') + const logAgent = agent!.replace(/[\r\n]/g, '') + const logChatId = chatId!.replace(/[\r\n]/g, '') console.info( `[RPC_PROXY] user=${auth.sub} host=${logHost} method=rename-session agent=${logAgent} chatId=${logChatId}` ) @@ -1338,15 +1238,17 @@ export function createRpcRouter(): Router { // Only host.headers carry the edge identity (x-clerum-edge-user-id and // the caller marker); the client Authorization is never forwarded, so // mcp-host's runtimeEdgeGuard stays satisfied. Body is re-serialized - // from the parsed JSON and forwarded verbatim: unlike the /model write - // path this route does no body-shape guard, since mcp-host owns title - // validation and returns its own 400 for a malformed body. + // with the canonical preflight title; MCP Host still validates it at + // its independent runtime boundary. const response = await fetch( - `${baseUrl}/v1/runtime/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(chatId)}/name`, + `${baseUrl}/v1/runtime/sessions/${encodeURIComponent(agent!)}/${encodeURIComponent(chatId!)}/name`, { method: 'PATCH', headers: { 'content-type': 'application/json', ...host.headers }, - body: JSON.stringify(req.body), + body: JSON.stringify({ + ...(req.body as Record), + title: validatedTitle, + }), signal: AbortSignal.timeout(config.upstreamTimeoutMs), } ) @@ -1371,16 +1273,13 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/models', requireRpcAuth, - requireScope('host:session:read'), + requireHostRpcPreflightScope('host:session:read'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } + const { hostRef, chatId } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -1392,7 +1291,6 @@ export function createRpcRouter(): Router { return } const baseUrl = host.url.replace(/\/+$/, '') - const chatId = typeof req.query.chatId === 'string' ? req.query.chatId.trim() : '' const upstreamUrl = chatId ? `${baseUrl}/v1/runtime/models?chatId=${encodeURIComponent(chatId)}` : `${baseUrl}/v1/runtime/models` @@ -1440,16 +1338,13 @@ export function createRpcRouter(): Router { '/rpc/hosts/:hostRef/model', requireRpcAuth, jsonBody, - requireScope('host:model:write'), + requireHostRpcPreflightScope('host:model:write'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } + const { hostRef, body } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -1460,11 +1355,6 @@ export function createRpcRouter(): Router { res.status(403).json({ error: 'Forbidden: user cannot access this host' }) return } - const body = req.body as Record - if (!body || typeof body !== 'object' || Array.isArray(body)) { - res.status(400).json({ error: 'Invalid set-model request payload' }) - return - } const baseUrl = host.url.replace(/\/+$/, '') console.info(`[RPC_PROXY] user=${auth.sub} host=${hostRef} method=set-model`) // Wake-eligible finite operation (§11.4): scope stays host:model:write. @@ -1506,21 +1396,14 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/tasks/:taskId/result', requireRpcAuth, - requireScope('host:message:invoke'), + requireHostRpcPreflightScope('host:message:invoke'), async (req: AuthedRequest, res) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - const taskId = String(req.params.taskId || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (!taskId || !/^[a-zA-Z0-9_-]+$/.test(taskId)) { - res.status(400).json({ error: 'Invalid taskId' }) - return - } + const { hostRef, taskId } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef, 404))) return const host = await resolveHostConnectionForUser( auth.sub, @@ -1537,7 +1420,7 @@ export function createRpcRouter(): Router { `[RPC_PROXY] user=${auth.sub} host=${hostRef} method=get-task-result taskId=${taskId}` ) const attemptTaskResult = async () => { - const result = await forwardTaskResultFromHost(host, taskId) + const result = await forwardTaskResultFromHost(host, taskId!) if (!result) { res.status(404).json({ error: 'Task result not found' }) return @@ -1577,21 +1460,14 @@ export function createRpcRouter(): Router { router.post( '/rpc/hosts/:hostRef/tasks/:taskId/cancel', requireRpcAuth, - requireScope('host:message:invoke'), + requireHostRpcPreflightScope('host:message:invoke'), async (req: AuthedRequest, res) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - const taskId = String(req.params.taskId || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (!taskId || !/^[a-zA-Z0-9_-]+$/.test(taskId)) { - res.status(400).json({ error: 'Invalid taskId' }) - return - } + const { hostRef, taskId } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef, 404))) return const host = await resolveHostConnectionForUser( auth.sub, @@ -1609,7 +1485,7 @@ export function createRpcRouter(): Router { ) const attemptCancel = async () => { - const result = await forwardCancelToHost(host, taskId, auth.sub) + const result = await forwardCancelToHost(host, taskId!, auth.sub) if (result.body) { res .status(result.status) @@ -1653,7 +1529,7 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/artifacts', requireRpcAuth, - requireScope('host:task:read'), + requireHostRefCheckpointScope('host:task:read'), resolveArtifactReadHost, artifactReadEdgeRateLimit, async (req: AuthedRequest, res, next) => { @@ -1698,7 +1574,7 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/artifacts/:filename/download', requireRpcAuth, - requireScope('host:task:read'), + bindHostRpcScope('host:task:read'), (req: AuthedRequest, res, next) => { const filename = String(req.params.filename || '').trim() if ( @@ -1713,6 +1589,7 @@ export function createRpcRouter(): Router { } next() }, + runHostRefCheckpoint, resolveArtifactReadHost, artifactReadEdgeRateLimit, async (req: AuthedRequest, res, next) => { @@ -1782,17 +1659,14 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/activity', requireRpcAuth, - requireScope('host:activity:read'), + requireHostRpcPreflightScope('host:activity:read'), async (req: AuthedRequest, res, next) => { // Host runtime read-only activity timeline snapshot. try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } + const { hostRef, limit = 50 } = getHostRpcPreflight(req) + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, hostRef, @@ -1803,7 +1677,6 @@ export function createRpcRouter(): Router { res.status(403).json({ error: 'Forbidden: user cannot access this host' }) return } - const limit = Number(req.query.limit || 50) const sinceEventId = typeof req.query.sinceEventId === 'string' ? req.query.sinceEventId : undefined const activity = await forwardHostActivity(host, limit, sinceEventId) @@ -1822,17 +1695,15 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/status', requireRpcAuth, - requireScope('host:status:read'), + requireHostRpcPreflightScope('host:status:read'), async (req: AuthedRequest, res, next) => { // Host runtime read snapshot (REST-oriented) scoped to host:status:read. try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } + const { hostRef } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, @@ -1866,17 +1737,15 @@ export function createRpcRouter(): Router { router.get( '/rpc/hosts/:hostRef/health', requireRpcAuth, - requireScope('host:health:read'), + requireHostRpcPreflightScope('host:health:read'), async (req: AuthedRequest, res, next) => { // Host runtime health/liveness read path (REST-oriented) scoped to host:health:read. try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef) { - res.status(400).json({ error: 'hostRef is required' }) - return - } + const { hostRef } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, diff --git a/rpc-proxy/src/routes/rpcHostActivityStream.ts b/rpc-proxy/src/routes/rpcHostActivityStream.ts index 24f8678953..d7ed6bb7a6 100644 --- a/rpc-proxy/src/routes/rpcHostActivityStream.ts +++ b/rpc-proxy/src/routes/rpcHostActivityStream.ts @@ -1,12 +1,14 @@ import { Router } from 'express' import { config } from '../config.js' +import { getHostRpcPreflight } from '../hostRpcPreflight.js' import { type AuthedRequest, extractAuthToken, + requireHostRpcPreflightScope, requireRpcAuth, - requireScope, } from '../middleware/auth.js' import { runtimeHostEdgeContext } from '../routeActionBindingV2.js' +import { admitLegacyHostRpcRequest } from '../services/hostRpcAdmission.js' import { resolveHostConnectionForUser } from '../services/mcpProxyService.js' const activeStreamCountsByUser = new Map() @@ -44,11 +46,6 @@ function decrementCounter(map: Map, key: string): void { else map.set(key, next) } -function isWildcardOrInvalidHostRef(hostRef: string): boolean { - if (!hostRef.trim()) return true - return /[*%]/.test(hostRef) -} - function sanitizedStreamErrorMessage(): string { return 'Activity temporarily unavailable' } @@ -80,22 +77,14 @@ export function createRpcHostActivityStreamRouter(): Router { router.get( '/rpc/hosts/:hostRef/activity/stream', requireRpcAuth, - requireScope('host:activity:read'), + requireHostRpcPreflightScope('host:activity:read'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef || isWildcardOrInvalidHostRef(hostRef)) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (Number(req.headers['content-length'] || 0) > 0) { - res - .status(400) - .json({ error: 'Activity stream is read-only and does not accept request bodies' }) - return - } + const { hostRef } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, diff --git a/rpc-proxy/src/routes/rpcHostProgressStream.ts b/rpc-proxy/src/routes/rpcHostProgressStream.ts index 72e4d2afaa..698a99a6b4 100644 --- a/rpc-proxy/src/routes/rpcHostProgressStream.ts +++ b/rpc-proxy/src/routes/rpcHostProgressStream.ts @@ -1,19 +1,16 @@ import { Router } from 'express' import { config } from '../config.js' +import { getHostRpcPreflight } from '../hostRpcPreflight.js' import { type AuthedRequest, extractAuthToken, + requireHostRpcPreflightScope, requireRpcAuth, - requireScope, } from '../middleware/auth.js' import { runtimeHostEdgeContext } from '../routeActionBindingV2.js' +import { admitLegacyHostRpcRequest } from '../services/hostRpcAdmission.js' import { resolveHostConnectionForUser } from '../services/mcpProxyService.js' -function isWildcardOrInvalidHostRef(hostRef: string): boolean { - if (!hostRef.trim()) return true - return /[*%]/.test(hostRef) -} - function sanitizedStreamErrorMessage(): string { return 'Progress stream temporarily unavailable' } @@ -45,30 +42,14 @@ export function createRpcHostProgressStreamRouter(): Router { router.get( '/rpc/hosts/:hostRef/tasks/:taskId/progress/stream', requireRpcAuth, - requireScope('host:activity:read'), + requireHostRpcPreflightScope('host:activity:read'), async (req: AuthedRequest, res, next) => { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - const taskId = String(req.params.taskId || '').trim() + const { hostRef, taskId } = getHostRpcPreflight(req) - if (!hostRef || isWildcardOrInvalidHostRef(hostRef)) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (!taskId || taskId.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(taskId)) { - res - .status(400) - .json({ error: 'taskId is required and must be alphanumeric (max 128 chars)' }) - return - } - if (Number(req.headers['content-length'] || 0) > 0) { - res - .status(400) - .json({ error: 'Progress stream is read-only and does not accept request bodies' }) - return - } + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, diff --git a/rpc-proxy/src/routes/rpcHostStatusStream.ts b/rpc-proxy/src/routes/rpcHostStatusStream.ts index d4ff36ab89..6d30d951ae 100644 --- a/rpc-proxy/src/routes/rpcHostStatusStream.ts +++ b/rpc-proxy/src/routes/rpcHostStatusStream.ts @@ -1,12 +1,14 @@ import { Router } from 'express' import { config } from '../config.js' +import { getHostRpcPreflight } from '../hostRpcPreflight.js' import { type AuthedRequest, extractAuthToken, + requireHostRpcPreflightScope, requireRpcAuth, - requireScope, } from '../middleware/auth.js' import { runtimeHostEdgeContext } from '../routeActionBindingV2.js' +import { admitLegacyHostRpcRequest } from '../services/hostRpcAdmission.js' import { UpstreamHostError, forwardHostStatus, @@ -47,11 +49,6 @@ function decrementCounter(map: Map, key: string): void { else map.set(key, next) } -function isWildcardOrInvalidHostRef(hostRef: string): boolean { - if (!hostRef.trim()) return true - return /[*%]/.test(hostRef) -} - function sanitizedStreamErrorMessage(): string { return 'Status temporarily unavailable' } @@ -73,7 +70,7 @@ export function createRpcHostStatusStreamRouter(): Router { router.get( '/rpc/hosts/:hostRef/status/stream', requireRpcAuth, - requireScope('host:status:read'), + requireHostRpcPreflightScope('host:status:read'), async (req: AuthedRequest, res, next) => { // Read-only telemetry channel: // - server-sent status updates from mcp-host to desktop clients @@ -81,17 +78,9 @@ export function createRpcHostStatusStreamRouter(): Router { try { const auth = req.auth! const rpcAccessToken = extractAuthToken(req) - const hostRef = String(req.params.hostRef || '').trim() - if (!hostRef || isWildcardOrInvalidHostRef(hostRef)) { - res.status(400).json({ error: 'hostRef is required' }) - return - } - if (Number(req.headers['content-length'] || 0) > 0) { - res - .status(400) - .json({ error: 'Status stream is read-only and does not accept request bodies' }) - return - } + const { hostRef } = getHostRpcPreflight(req) + + if (!(await admitLegacyHostRpcRequest(req, res, hostRef))) return const host = await resolveHostConnectionForUser( auth.sub, diff --git a/rpc-proxy/src/services/controlApiRestService.ts b/rpc-proxy/src/services/controlApiRestService.ts index d2a891af88..4aef4d94c9 100644 --- a/rpc-proxy/src/services/controlApiRestService.ts +++ b/rpc-proxy/src/services/controlApiRestService.ts @@ -142,6 +142,98 @@ export async function admitLegacySessionCreation( return { allowed: false, status: 429, retryAfterSeconds, headers } } +export class ControlApiHostRpcAdmissionError extends Error { + constructor( + readonly status: 429 | 503, + readonly body: { error: string; retryAfterSeconds?: number }, + readonly headers: Record + ) { + super(`Control API Host-RPC admission returned ${status}`) + this.name = 'ControlApiHostRpcAdmissionError' + } +} + +/** Charge one legacy non-message Host-RPC request through Control API authority. */ +export async function requestHostRpcAdmission( + userId: string, + hostRef: string, + rpcAccessToken: string, + options: { fetchImpl?: typeof fetch } = {} +): Promise { + const response = await (options.fetchImpl ?? fetch)( + `${controlApiBaseUrl()}/rpc/access/users/${encodeURIComponent(userId)}/mcp-hosts/${encodeURIComponent(hostRef)}/host-rpc-admission`, + { + method: 'POST', + headers: controlApiHeaders(rpcAccessToken), + signal: upstreamAbortSignal(), + } + ) + if (response.status === 204) return + if (response.status === 401 || response.status === 403) { + await drainBody(response) + throw new ControlApiHostAccessRejectedError(response.status) + } + if (response.status === 503) { + const body = (await response.json().catch(() => null)) as { error?: unknown } | null + if (body?.error === 'host_rpc_admission_unavailable') { + throw new ControlApiHostRpcAdmissionError( + 503, + { error: 'host_rpc_admission_unavailable' }, + {} + ) + } + throw new ControlApiHostRpcAdmissionError(503, { error: 'host_rpc_admission_unavailable' }, {}) + } + if (response.status === 429) { + const body = (await response.json().catch(() => null)) as { + error?: unknown + retryAfterSeconds?: unknown + } | null + const names = [ + 'retry-after', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + ] as const + const headers = Object.fromEntries(names.map(name => [name, response.headers.get(name)])) + const parseCanonicalInteger = (value: string | null): number | null => { + if (value === null || !/^(0|[1-9]\d*)$/.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? parsed : null + } + const retryAfter = parseCanonicalInteger(headers['retry-after']) + const limit = parseCanonicalInteger(headers['x-ratelimit-limit']) + const remaining = parseCanonicalInteger(headers['x-ratelimit-remaining']) + const reset = parseCanonicalInteger(headers['x-ratelimit-reset']) + if ( + body?.error === 'Too Many Requests' && + Number.isSafeInteger(body.retryAfterSeconds) && + Number(body.retryAfterSeconds) > 0 && + retryAfter !== null && + retryAfter === Number(body.retryAfterSeconds) && + limit !== null && + limit > 0 && + remaining === 0 && + reset !== null && + reset > 0 + ) { + throw new ControlApiHostRpcAdmissionError( + 429, + { error: 'Too Many Requests', retryAfterSeconds: Number(body.retryAfterSeconds) }, + { + 'Retry-After': String(headers['retry-after']), + 'X-RateLimit-Limit': String(headers['x-ratelimit-limit']), + 'X-RateLimit-Remaining': String(headers['x-ratelimit-remaining']), + 'X-RateLimit-Reset': String(headers['x-ratelimit-reset']), + } + ) + } + throw new ControlApiHostRpcAdmissionError(503, { error: 'host_rpc_admission_unavailable' }, {}) + } + await drainBody(response) + throw new ControlApiHostRpcAdmissionError(503, { error: 'host_rpc_admission_unavailable' }, {}) +} + // Typed rejection for the connectors read-model, mirroring the host rail above. // A generic Error collapses to 500 in the app error handler, which the desktop // reads as non-refreshable — so an expired/rotated rpc access token (401) would diff --git a/rpc-proxy/src/services/hostRpcAdmission.ts b/rpc-proxy/src/services/hostRpcAdmission.ts new file mode 100644 index 0000000000..118204194a --- /dev/null +++ b/rpc-proxy/src/services/hostRpcAdmission.ts @@ -0,0 +1,46 @@ +import type { Response } from 'express' +import type { AuthedRequest } from '../middleware/auth.js' +import { extractAuthToken } from '../middleware/auth.js' +import { + ControlApiHostAccessRejectedError, + ControlApiHostRpcAdmissionError, + requestHostRpcAdmission, +} from './controlApiRestService.js' + +/** + * Legacy adapter only. V2 has already consumed the shared budget inside its + * Control API action-authority checkpoint before route handlers run. + */ +export async function admitLegacyHostRpcRequest( + req: AuthedRequest, + res: Response, + hostRef: string, + inaccessibleStatus: 403 | 404 = 403 +): Promise { + if (req.userDelegationV2) return true + const auth = req.auth + if (!auth || !auth.hostRefs.includes(hostRef)) { + res + .status(auth ? inaccessibleStatus : 401) + .json({ error: auth ? 'Forbidden: user cannot access this host' : 'Unauthorized' }) + return false + } + try { + await requestHostRpcAdmission(auth.sub, hostRef, extractAuthToken(req)) + return true + } catch (error) { + if (error instanceof ControlApiHostRpcAdmissionError) { + for (const [name, value] of Object.entries(error.headers)) res.setHeader(name, value) + res.status(error.status).json(error.body) + return false + } + if (error instanceof ControlApiHostAccessRejectedError) { + res.status(error.status).json({ error: error.status === 401 ? 'Unauthorized' : 'Forbidden' }) + return false + } + // Network failures and malformed/missing Control API results are strict + // denial, never an allow before protected Host work. + res.status(503).json({ error: 'host_rpc_admission_unavailable' }) + return false + } +} diff --git a/rpc-proxy/test/routes.rpc.test.ts b/rpc-proxy/test/routes.rpc.test.ts index bc93e74d66..bef0218e5a 100644 --- a/rpc-proxy/test/routes.rpc.test.ts +++ b/rpc-proxy/test/routes.rpc.test.ts @@ -36,6 +36,7 @@ const controlApiMock = vi.hoisted(() => ({ this.name = 'ControlApiHostAccessRejectedError' } }, + requestHostRpcAdmission: async () => undefined, requestHostWakeFromControlApi: vi.fn(), })) diff --git a/rpc-proxy/test/routes.rpc.v2-session-retry.test.ts b/rpc-proxy/test/routes.rpc.v2-session-retry.test.ts index 27e24edef7..dbf3b65754 100644 --- a/rpc-proxy/test/routes.rpc.v2-session-retry.test.ts +++ b/rpc-proxy/test/routes.rpc.v2-session-retry.test.ts @@ -40,6 +40,14 @@ vi.mock('../src/middleware/auth.js', () => ({ next() }, requireScope: () => (_req: unknown, _res: unknown, next: () => void) => next(), + requireHostRpcPreflightScope: () => (_req: unknown, _res: unknown, next: () => void) => next(), + requireHostRpcJsonBodyPreflightScope: () => (_req: unknown, _res: unknown, next: () => void) => + next(), + requireHostRefCheckpointScope: () => (_req: unknown, _res: unknown, next: () => void) => next(), + bindHostRpcScope: () => (_req: unknown, _res: unknown, next: () => void) => next(), + runHostRefCheckpoint: (_req: unknown, _res: unknown, next: () => void) => next(), + runHostRpcPreflightCheckpoint: (_req: unknown, _res: unknown, next: () => void) => next(), + requireV2SessionSearch: (_req: unknown, _res: unknown, next: () => void) => next(), })) vi.mock('../src/actionAuthorityV2.js', () => ({ diff --git a/rpc-proxy/test/routes.rpcHostActivityStream.test.ts b/rpc-proxy/test/routes.rpcHostActivityStream.test.ts index 347b38f155..f6dbcdb0b8 100644 --- a/rpc-proxy/test/routes.rpcHostActivityStream.test.ts +++ b/rpc-proxy/test/routes.rpcHostActivityStream.test.ts @@ -14,6 +14,9 @@ const authTokenMock = vi.hoisted(() => ({ const serviceMock = vi.hoisted(() => ({ resolveHostConnectionForUser: vi.fn(), })) +vi.mock('../src/services/hostRpcAdmission.js', () => ({ + admitLegacyHostRpcRequest: async () => true, +})) vi.mock('../src/authToken.js', () => authTokenMock) vi.mock('../src/services/mcpProxyService.js', () => serviceMock) diff --git a/rpc-proxy/test/routes.rpcHostStatusStream.test.ts b/rpc-proxy/test/routes.rpcHostStatusStream.test.ts index ee0daf3626..90ee10cf41 100644 --- a/rpc-proxy/test/routes.rpcHostStatusStream.test.ts +++ b/rpc-proxy/test/routes.rpcHostStatusStream.test.ts @@ -29,6 +29,9 @@ const serviceMock = vi.hoisted(() => ({ } }, })) +vi.mock('../src/services/hostRpcAdmission.js', () => ({ + admitLegacyHostRpcRequest: async () => true, +})) vi.mock('../src/authToken.js', () => authTokenMock) vi.mock('../src/services/mcpProxyService.js', () => serviceMock) diff --git a/scripts/tests/test-mcp-host-action-checkpoint-gateway.sh b/scripts/tests/test-mcp-host-action-checkpoint-gateway.sh index 99c9bfd6e3..5d9a644f85 100755 --- a/scripts/tests/test-mcp-host-action-checkpoint-gateway.sh +++ b/scripts/tests/test-mcp-host-action-checkpoint-gateway.sh @@ -64,6 +64,48 @@ raise "#{overlay}: broad internal route proxy is not permitted" if raise "#{overlay}: default-deny catch-all was removed" unless config.match?(/location\s+\/\s*\{\s*return 403;/m) +rpc_gateway = documents.find do |document| + document.is_a?(Hash) && document['kind'] == 'ConfigMap' && + document.dig('metadata', 'name') == 'control-api-rpc-gateway' +end +raise "#{overlay}: rendered RPC gateway ConfigMap missing" unless rpc_gateway + +rpc_config = rpc_gateway.dig('data', 'nginx.conf') +raise "#{overlay}: rendered RPC gateway nginx.conf missing" unless rpc_config.is_a?(String) + +rpc_lines = rpc_config.lines +admission_line = 'location ~ ^/api/v1/rpc/access/users/[^/]+/mcp-hosts/[^/]+/host-rpc-admission$ {' +admission_matches = rpc_lines.each_index.select { |index| rpc_lines[index].strip == admission_line } +raise "#{overlay}: expected one exact Host-RPC admission location, found #{admission_matches.length}" unless + admission_matches.length == 1 + +admission_depth = 0 +admission_block = [] +rpc_lines.drop(admission_matches.first).each do |line| + admission_block << line + admission_depth += line.count('{') - line.count('}') + break if admission_depth.zero? +end +admission_location = admission_block.join +raise "#{overlay}: Host-RPC admission location is not POST-only" unless + admission_location.match?(/limit_except\s+POST\s*\{[^}]*deny all;/m) +raise "#{overlay}: Host-RPC admission location does not proxy to Control API" unless + admission_location.include?('proxy_pass http://control_api_upstream;') +[ + 'proxy_set_header Authorization $http_authorization;', + 'proxy_set_header X-Service-Token $http_x_service_token;', + 'proxy_set_header X-Rpc-Access-Token $http_x_rpc_access_token;', +].each do |header| + raise "#{overlay}: Host-RPC admission does not forward #{header}" unless + admission_location.include?(header) +end +raise "#{overlay}: Host-RPC admission disables request headers" if + admission_location.match?(/proxy_pass_request_headers\s+off\s*;/) +raise "#{overlay}: broad internal route proxy is not permitted in RPC gateway" if + rpc_config.match?(/location\s+(?:\^~\s+)?\/api\/v1\/internal(?:\/|\s*\{)/) +raise "#{overlay}: RPC gateway default-deny catch-all was removed" unless + rpc_config.match?(/location\s+\/\s*\{\s*return 403;/m) + gateway_policy = documents.find do |document| document.is_a?(Hash) && document['kind'] == 'NetworkPolicy' && document.dig('metadata', 'name') == 'nginx-workflow-approval-gateway' @@ -102,6 +144,6 @@ mcp_host_egress = mcp_host_policy.dig('spec', 'egress').find do |rule| end raise "#{overlay}: MCP Host cannot reach the narrow gateway on 8092" unless mcp_host_egress -puts "PASS #{overlay}: #{path} is an exact POST route through the rendered gateway" +puts "PASS #{overlay}: exact checkpoint and Host-RPC admission routes remain bounded" RUBY done