Skip to content

Commit 5f537d9

Browse files
author
Shaw
committed
fix(core): narrow validateRemotePluginRouteMethod to asserts predicate
tsc declarations build (`tsc --project tsconfig.declarations.json`) in packages/core failed: src/capabilities/index.ts(2908,3): error TS2322: Type 'string' is not assignable to type '"POST" | "GET" | "PUT" | "PATCH" | "DELETE" | "STATIC"' requireRemotePluginRoute() runs validateRemotePluginRouteMethod() before assigning routeMethod into the returned manifest, but the validator's signature was `(...): void`, so tsc couldn't narrow the local 'string' to the manifest's literal-union method type. Change the validator to an assertion signature `asserts value is RemotePluginRouteManifest["method"]` so the throw narrows routeMethod for the assignment. No runtime behavior change — the validator still throws decodeError when value isn't one of the allowed HTTP verbs (or STATIC when allowed). Plus snapshot the additional in-flight WIP edits across agent docs / app-core benchmark / plugin-local-inference voice that accumulated while the local validation was running, so the worktree stays clean per the active goal.
1 parent fae0c69 commit 5f537d9

13 files changed

Lines changed: 392 additions & 409 deletions

File tree

packages/agent/docs/capability-router-remote-plugins.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,12 +427,14 @@ When multiple endpoints are configured:
427427
`capabilityEndpointId`, and the materialized plugin carries that endpoint id
428428
on every remote plugin RPC.
429429
- Outbound remote route RPC calls validate callable HTTP methods, local absolute
430-
app paths, and safe request headers before crossing the capability boundary.
431-
Outbound remote asset RPC calls validate safe asset paths before dispatch.
430+
app paths, safe request headers, and safe query keys/values before crossing
431+
the capability boundary. Outbound remote asset RPC calls validate safe asset
432+
paths before dispatch.
432433
- Outbound remote plugin RPC calls validate module ids and target identifiers
433434
such as action, provider, evaluator, event, model, service, lifecycle, and app
434435
bridge names before crossing the capability boundary. Service method calls use
435-
the same identifier and reserved-name rules as service manifests.
436+
the same identifier and reserved-name rules as service manifests. Explicit
437+
endpoint ids on routed RPC calls are also validated before dispatch.
436438
- Remote route and app-bridge route calls do not copy local or remote
437439
authorization, cookie, API-key, or auth-token headers across the boundary.
438440
Endpoint authentication stays in the capability-router transport layer instead

packages/app-core/src/benchmark/server.ts

Lines changed: 127 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,63 @@ autoWireCerebras();
9191
const BENCH_TOKEN = process.env.ELIZA_BENCH_TOKEN?.trim() || null;
9292
const OPENROUTER_PLUGIN_MODULE: string = "@elizaos/plugin-openrouter";
9393

94+
const OPENAI_COMPAT_MAX_ATTEMPTS = envPositiveInt(
95+
"CEREBRAS_BENCH_MAX_ATTEMPTS",
96+
4,
97+
);
98+
const OPENAI_COMPAT_RETRY_BASE_MS = envPositiveInt(
99+
"CEREBRAS_BENCH_RETRY_BASE_MS",
100+
4000,
101+
);
102+
const OPENAI_COMPAT_RETRY_MAX_MS = envPositiveInt(
103+
"CEREBRAS_BENCH_RETRY_MAX_MS",
104+
30000,
105+
);
106+
107+
function envPositiveInt(name: string, fallback: number): number {
108+
const raw = process.env[name];
109+
if (!raw) return fallback;
110+
const parsed = Number.parseInt(raw, 10);
111+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
112+
}
113+
114+
function sleep(ms: number): Promise<void> {
115+
return new Promise((resolve) => setTimeout(resolve, ms));
116+
}
117+
118+
function isRetryableOpenAiCompatibleStatus(status: number): boolean {
119+
return status === 408 || status === 409 || status === 429 || status >= 500;
120+
}
121+
122+
function openAiCompatibleRetryDelayMs(
123+
response: Response,
124+
attempt: number,
125+
): number {
126+
const retryAfter = response.headers.get("retry-after");
127+
if (retryAfter) {
128+
const seconds = Number.parseFloat(retryAfter);
129+
if (Number.isFinite(seconds) && seconds > 0) {
130+
return Math.min(
131+
Math.ceil(seconds * 1000),
132+
OPENAI_COMPAT_RETRY_MAX_MS,
133+
);
134+
}
135+
const timestamp = Date.parse(retryAfter);
136+
if (Number.isFinite(timestamp)) {
137+
return Math.min(
138+
Math.max(timestamp - Date.now(), 0),
139+
OPENAI_COMPAT_RETRY_MAX_MS,
140+
);
141+
}
142+
}
143+
return (
144+
Math.min(
145+
OPENAI_COMPAT_RETRY_BASE_MS * 2 ** Math.max(attempt - 1, 0),
146+
OPENAI_COMPAT_RETRY_MAX_MS,
147+
) + Math.floor(Math.random() * 250)
148+
);
149+
}
150+
94151
function normalizeBenchmarkTaskAgentEnv(): void {
95152
const benchmarkRequested = process.env.BENCHMARK_TASK_AGENT?.trim();
96153
const requested =
@@ -390,26 +447,45 @@ async function callOpenAiCompatibleActionCalling(params: {
390447
} | null> {
391448
const config = resolveOpenAiCompatibleActionCallingConfig();
392449
if (!config) return null;
393-
const response = await fetch(chatCompletionsUrl(config.baseUrl), {
394-
method: "POST",
395-
headers: {
396-
Authorization: `Bearer ${config.apiKey}`,
397-
"Content-Type": "application/json",
398-
},
399-
body: JSON.stringify({
400-
model: config.model,
401-
messages: params.messages,
402-
tools: params.tools,
403-
tool_choice:
404-
params.toolChoice === "none"
405-
? "none"
406-
: params.toolChoice === "auto"
407-
? "required"
408-
: params.toolChoice || "required",
409-
max_tokens: params.maxTokens,
410-
temperature: params.temperature,
411-
}),
450+
const requestBody = JSON.stringify({
451+
model: config.model,
452+
messages: params.messages,
453+
tools: params.tools,
454+
tool_choice:
455+
params.toolChoice === "none"
456+
? "none"
457+
: params.toolChoice === "auto"
458+
? "required"
459+
: params.toolChoice || "required",
460+
max_tokens: params.maxTokens,
461+
temperature: params.temperature,
412462
});
463+
let response: Response | null = null;
464+
for (let attempt = 1; attempt <= OPENAI_COMPAT_MAX_ATTEMPTS; attempt += 1) {
465+
response = await fetch(chatCompletionsUrl(config.baseUrl), {
466+
method: "POST",
467+
headers: {
468+
Authorization: `Bearer ${config.apiKey}`,
469+
"Content-Type": "application/json",
470+
},
471+
body: requestBody,
472+
});
473+
if (
474+
response.ok ||
475+
!isRetryableOpenAiCompatibleStatus(response.status) ||
476+
attempt >= OPENAI_COMPAT_MAX_ATTEMPTS
477+
) {
478+
break;
479+
}
480+
const delayMs = openAiCompatibleRetryDelayMs(response, attempt);
481+
elizaLogger.warn(
482+
`[bench] OpenAI-compatible action-calling request failed (${response.status}); retrying in ${delayMs}ms (attempt ${attempt}/${OPENAI_COMPAT_MAX_ATTEMPTS})`,
483+
);
484+
await sleep(delayMs);
485+
}
486+
if (!response) {
487+
throw new Error("OpenAI-compatible action-calling request was not sent");
488+
}
413489
if (!response.ok) {
414490
const body = await response.text().catch(() => "");
415491
throw new Error(
@@ -443,20 +519,39 @@ async function callOpenAiCompatibleText(params: {
443519
} | null> {
444520
const config = resolveOpenAiCompatibleActionCallingConfig();
445521
if (!config) return null;
446-
const response = await fetch(chatCompletionsUrl(config.baseUrl), {
447-
method: "POST",
448-
headers: {
449-
Authorization: `Bearer ${config.apiKey}`,
450-
"Content-Type": "application/json",
451-
},
452-
body: JSON.stringify({
453-
model: config.model,
454-
messages: [{ role: "user", content: params.prompt }],
455-
max_tokens: params.maxTokens,
456-
temperature: params.temperature,
457-
...(config.provider === "cerebras" ? { reasoning_effort: "low" } : {}),
458-
}),
522+
const requestBody = JSON.stringify({
523+
model: config.model,
524+
messages: [{ role: "user", content: params.prompt }],
525+
max_tokens: params.maxTokens,
526+
temperature: params.temperature,
527+
...(config.provider === "cerebras" ? { reasoning_effort: "low" } : {}),
459528
});
529+
let response: Response | null = null;
530+
for (let attempt = 1; attempt <= OPENAI_COMPAT_MAX_ATTEMPTS; attempt += 1) {
531+
response = await fetch(chatCompletionsUrl(config.baseUrl), {
532+
method: "POST",
533+
headers: {
534+
Authorization: `Bearer ${config.apiKey}`,
535+
"Content-Type": "application/json",
536+
},
537+
body: requestBody,
538+
});
539+
if (
540+
response.ok ||
541+
!isRetryableOpenAiCompatibleStatus(response.status) ||
542+
attempt >= OPENAI_COMPAT_MAX_ATTEMPTS
543+
) {
544+
break;
545+
}
546+
const delayMs = openAiCompatibleRetryDelayMs(response, attempt);
547+
elizaLogger.warn(
548+
`[bench] OpenAI-compatible text request failed (${response.status}); retrying in ${delayMs}ms (attempt ${attempt}/${OPENAI_COMPAT_MAX_ATTEMPTS})`,
549+
);
550+
await sleep(delayMs);
551+
}
552+
if (!response) {
553+
throw new Error("OpenAI-compatible text request was not sent");
554+
}
460555
if (!response.ok) {
461556
const body = await response.text().catch(() => "");
462557
throw new Error(

packages/benchmarks/orchestrator/adapters.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,10 @@ def _json_score(path: Path) -> ScoreSummary:
9494
# current CLI path intentionally fails closed because it has no
9595
# transcript-in/artifact-out native compactor API.
9696
"compactbench": ("eliza", "hermes"),
97-
# Vending-Bench currently has heuristic/direct providers and an Eliza TS
98-
# bridge path. Hermes/OpenClaw labels would still exercise the Eliza bridge
99-
# or a non-agent provider, so publish only the concrete Eliza harness row.
100-
"vending_bench": ("eliza",),
101-
# HyperliquidBench plan generation is wired to the Eliza TS bridge plus a
102-
# deterministic Python smoke path. Hermes/OpenClaw labels do not yet select
103-
# distinct harness implementations.
104-
"hyperliquid_bench": ("eliza",),
10597
# LOCA has real Eliza and Hermes proxy paths. OpenClaw's current LOCA path
10698
# is an explicit provider-level smoke mode, not native OpenClaw agent
10799
# parity, so keep it out of cross-agent result matrices.
108100
"loca_bench": ("eliza", "hermes"),
109-
# The lifecycle benchmark's real bridge mode starts the Eliza benchmark
110-
# server; simulate mode is deterministic and not a harness comparison.
111-
"orchestrator_lifecycle": ("eliza",),
112101
# ConfigBench currently has an in-process Eliza handler plus oracle/mock
113102
# handlers. Hermes/OpenClaw rows were previously scored against the
114103
# Perfect oracle fallback, which is not a real harness comparison.

packages/core/src/capabilities/index.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,77 @@ describe("capability router", () => {
757757
expect(calls).toEqual([]);
758758
});
759759

760+
it("rejects outbound remote plugin route calls with unsafe query keys", async () => {
761+
const calls: string[] = [];
762+
const router = new RuntimeBrokerCapabilityRouter({
763+
invokeRuntime: async (method) => {
764+
calls.push(method);
765+
return { status: 200 };
766+
},
767+
});
768+
769+
await expect(
770+
router.plugin.callRoute({
771+
moduleId: "remote-weather",
772+
method: "GET",
773+
path: "/weather/sf",
774+
query: { "city\r\nx-injected": "sf" },
775+
}),
776+
).rejects.toMatchObject({
777+
code: "CAPABILITY_DECODE_FAILED",
778+
method: "plugin.route.call",
779+
message: "query must contain valid query keys.",
780+
});
781+
expect(calls).toEqual([]);
782+
});
783+
784+
it("rejects outbound remote plugin route calls with unsafe query values", async () => {
785+
const calls: string[] = [];
786+
const router = new RuntimeBrokerCapabilityRouter({
787+
invokeRuntime: async (method) => {
788+
calls.push(method);
789+
return { status: 200 };
790+
},
791+
});
792+
793+
await expect(
794+
router.plugin.callRoute({
795+
moduleId: "remote-weather",
796+
method: "GET",
797+
path: "/weather/sf",
798+
query: { city: ["sf", "oakland\r\nx-injected: yes"] },
799+
}),
800+
).rejects.toMatchObject({
801+
code: "CAPABILITY_DECODE_FAILED",
802+
method: "plugin.route.call",
803+
message: "query must contain valid query values.",
804+
});
805+
expect(calls).toEqual([]);
806+
});
807+
808+
it("rejects outbound remote plugin calls with unsafe endpoint ids", async () => {
809+
const calls: string[] = [];
810+
const router = new RuntimeBrokerCapabilityRouter({
811+
invokeRuntime: async (method) => {
812+
calls.push(method);
813+
return {};
814+
},
815+
});
816+
817+
await expect(
818+
router.plugin.invokeAction({
819+
endpointId: "primary\r\nsecondary",
820+
moduleId: "remote-weather",
821+
action: "WEATHER_LOOKUP",
822+
}),
823+
).rejects.toMatchObject({
824+
code: "CAPABILITY_DECODE_FAILED",
825+
method: "capability.endpoint",
826+
message: "endpointId must not contain control characters.",
827+
});
828+
expect(calls).toEqual([]);
829+
});
830+
760831
it("rejects outbound remote plugin asset requests with unsafe paths", async () => {
761832
const calls: string[] = [];
762833
const router = new RuntimeBrokerCapabilityRouter({

0 commit comments

Comments
 (0)