Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5978ec8
feat(control-api): add subject-wide Host RPC admission
josue-tech-94 Sep 24, 2026
0ab586c
test(rpc-proxy): avoid credential-assignment fixture false positive
josue-tech-94 Sep 24, 2026
5ab64a4
chore(control-api): reconcile shared limiter after PR2 sync
josue-tech-94 Sep 25, 2026
dc667a7
fix(rpc-proxy): close R1-B1 with canonical route preflight
josue-tech-94 Sep 25, 2026
1f75cf0
fix(rpc-proxy): restore session rename preflight routing
josue-tech-94 Sep 25, 2026
e00170d
fix(rpc-proxy): r2-b2 preflight approval request IDs
josue-tech-94 Sep 25, 2026
78af096
fix(rpc-proxy): r3-b1 allow exact Host-RPC gateway path
josue-tech-94 Sep 28, 2026
575f3ea
fix(control-api): r3-m1 admit wake-only host scope
josue-tech-94 Sep 28, 2026
103c3ec
fix(rpc-proxy): r3-m2 reject malformed admission headers
josue-tech-94 Sep 28, 2026
5145b60
fix(rpc-proxy): r3-h1 preflight malformed sessions cursors
josue-tech-94 Sep 28, 2026
743316e
fix(rpc-proxy): r3-m3 preflight invalid activity limits
josue-tech-94 Sep 28, 2026
8d5249d
fix(rpc-proxy): r3-m4 preflight progress Host refs
josue-tech-94 Sep 28, 2026
ddb1f43
fix(control-api): r3-m5 report strict rate-limit backend failures
josue-tech-94 Sep 28, 2026
916e7f3
fix(rpc-proxy): r3-l1 preserve session path error precedence
josue-tech-94 Sep 28, 2026
5be7641
fix(rpc-proxy): r3-m6 reject chunked Host stream bodies
josue-tech-94 Sep 28, 2026
a34b75f
fix(contracts): r3-m7 bound title code-point allocation
josue-tech-94 Sep 28, 2026
364ab82
test(rpc-proxy): r3-h1 use canonical sessions cursor fixture
josue-tech-94 Sep 28, 2026
ce72bcb
test(rpc-proxy): cover remaining Spec 65 admission routes
josue-tech-94 Sep 28, 2026
c41c111
style: format action-context contract changes
josue-tech-94 Sep 28, 2026
65182d3
chore(stack): restore accepted PR2.1 oracle tree
josue-tech-94 Sep 30, 2026
f0b96a7
fix(rpc-proxy): r27-m1 validate host refs across rpc routes
josue-tech-94 Sep 23, 2026
cf300fe
fix(rpc-proxy): order W1 before v2 checkpoints
josue-tech-94 Sep 24, 2026
4f3b1dd
refactor(rpc-proxy): compose Spec 65 preflight with W1
josue-tech-94 Sep 25, 2026
e89e051
test(rpc-proxy): r2-m1 make W1 tests reach validation
josue-tech-94 Sep 28, 2026
f52c984
chore(stack): align PR2.2 preflight wording with parent
josue-tech-94 Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions control-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions control-api/src/routes/internal/actionAuthorityCheckpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
admitHostMessage,
respondHostMessageAdmissionFailure,
} from '../../services/hostMessageAdmission.js'
import {
admitHostRpc,
requiresHostRpcAdmission,
respondHostRpcAdmissionFailure,
} from '../../services/hostRpcAdmission.js'
import {
issueHostMessageAdmissionReceipt,
verifyHostMessageAdmissionReceipt,
Expand Down Expand Up @@ -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({
Expand Down
43 changes: 43 additions & 0 deletions control-api/src/routes/rpc-access/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
admitHostMessage,
respondHostMessageAdmissionFailure,
} from '../../services/hostMessageAdmission.js'
import { admitHostRpc, respondHostRpcAdmissionFailure } from '../../services/hostRpcAdmission.js'
import {
type DirectRunAttributionBindingService,
DirectRunBindingConflictError,
Expand Down Expand Up @@ -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]),
Expand Down
52 changes: 18 additions & 34 deletions control-api/src/services/hostMessageAdmission.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,39 @@
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(
verifiedSubject: string,
check: typeof checkAndIncrement = checkAndIncrement
): Promise<HostMessageAdmission> {
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<HostMessageAdmission, { status: 'allowed' }>
): 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
)
}
57 changes: 57 additions & 0 deletions control-api/src/services/hostRpcAdmission.ts
Original file line number Diff line number Diff line change
@@ -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<StrictFixedWindowAdmission> {
if (!verifiedSubject) return { status: 'unavailable' }
return admitStrictFixedWindow(
hostRpcAdmissionBucketKey(verifiedSubject),
config.hostRpcAdmissionRlPerMin,
check
)
}

export function respondHostRpcAdmissionFailure(
res: import('express').Response,
admission: Exclude<StrictFixedWindowAdmission, { status: 'allowed' }>
): void {
respondStrictFixedWindowFailure(
res,
admission,
'host_rpc_admission_unavailable',
config.hostRpcAdmissionRlPerMin
)
}
Loading
Loading