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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/adapters/kiro/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
});
return {
response,
abortSignal: requestAbortSignal,
inputTokens: retry.inputTokens,
contextInputEstimate: retry.contextInputEstimate,
nameMap: retry.nameMap,
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/kiro/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { noteKiroTransientThrottle } from "../kiro-retry";
import { KiroThinkingParser } from "../kiro-thinking";
import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "../kiro-truncation";
import { isValidKiroConversationId } from "../kiro-wire";
import { readDisplaySafeErrorPayloadText } from "../upstream-http-error";
import { tagKiroReasoningBlob } from "./reasoning";
import { estimateKiroTokens, kiroUpstreamContextWindow } from "./usage";

Expand Down Expand Up @@ -74,6 +75,7 @@ function createKiroAttemptRetention(budget: TranslatorBudget): KiroAttemptRetent

interface KiroFallbackAttempt {
response: Response;
abortSignal?: AbortSignal;
inputTokens: number;
contextInputEstimate: number;
nameMap: Map<string, string>;
Expand Down Expand Up @@ -1092,7 +1094,7 @@ export async function* parseKiroStream(
firstResult.releaseRetained();
fallback.releaseRequestBody?.();
if (!fallback.response.ok) {
const payload = await fallback.response.text().catch(() => "");
const payload = await readDisplaySafeErrorPayloadText(fallback.response, fallback.abortSignal);
const failure = classifyKiroHttpError(fallback.response.status, fallback.response.headers, payload);
yield {
type: "error",
Expand Down
11 changes: 8 additions & 3 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function effectiveBlockedSkillNames(cc?: Pick<OcxClaudeCodeConfig, "block
/** Injected-skill payloads below this size are never stubbed (not worth it). */
const SKILL_ELISION_MIN_CHARS = 10_000;
const SKILL_TEXT_MARKER = "Base directory for this skill: ";
const SKILL_TEXT_PATH_MAX_CHARS = 4_096;

interface SkillElisionContext {
/** Skill-tool call ids whose input names a blocked skill (result-body carrier). */
Expand All @@ -138,11 +139,15 @@ const NO_ELISION: SkillElisionContext = { callIds: new Set(), names: [] };
function maybeElideSkillText(text: string, names: readonly string[]): string {
if (names.length === 0 || text.length < SKILL_ELISION_MIN_CHARS) return text;
if (!text.startsWith(SKILL_TEXT_MARKER)) return text;
const firstLineEnd = text.indexOf("\n");
const dir = text.slice(SKILL_TEXT_MARKER.length, firstLineEnd === -1 ? text.length : firstLineEnd).trim();
const pathStart = SKILL_TEXT_MARKER.length;
const pathPrefix = text.slice(pathStart, pathStart + SKILL_TEXT_PATH_MAX_CHARS + 1);
const firstLineEnd = pathPrefix.indexOf("\n");
if (firstLineEnd === -1 && pathPrefix.length > SKILL_TEXT_PATH_MAX_CHARS) return text;
const dir = pathPrefix.slice(0, firstLineEnd === -1 ? pathPrefix.length : firstLineEnd).trim();
// Windows clients send `C:\Users\...\claude-api`; normalize separators before
// basenaming (repo precedent: src/codex/inject.ts isOpencodexCatalogPath).
const base = dir.replace(/\\/g, "/").split("/").filter(Boolean).pop()?.toLowerCase() ?? "";
const normalizedDir = dir.replace(/\\/g, "/").replace(/\/+$/, "");
const base = normalizedDir.slice(normalizedDir.lastIndexOf("/") + 1).toLowerCase();
if (!names.includes(base)) return text;
return `[opencodex] '${base}' skill document bundle (${text.length} chars) elided for routed models `
+ "(claudeCode.blockedSkills). The skill is loaded; answer from general knowledge instead of citing the bundle.";
Expand Down
4 changes: 4 additions & 0 deletions src/server/responses/encrypted-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ function looksLikeUnknownOpaqueSlot(payload: string): boolean {
*/
const FERNET_TOKEN_CANDIDATE = /g[A-Za-z0-9_-]{97,}={0,2}/g;
const FERNET_TOKEN_BOUNDARY_CHAR = /[A-Za-z0-9_=-]/;
// A normal mixed agent task contains one encrypted body. Keep pathological slots
// from amplifying into an attacker-controlled number of request parts.
const MAX_EMBEDDED_FERNET_RUNS_PER_SLOT = 64;

interface FernetTokenRun {
index: number;
Expand Down Expand Up @@ -173,6 +176,7 @@ function fernetTokenRuns(payload: string): FernetTokenRun[] {
if (after && FERNET_TOKEN_BOUNDARY_CHAR.test(after)) continue;
if (!isStructurallyValidFernetToken(token)) continue;
runs.push({ index, token });
if (runs.length >= MAX_EMBEDDED_FERNET_RUNS_PER_SLOT) break;
}
return runs;
}
Expand Down
6 changes: 6 additions & 0 deletions tests/claude-integration/claude-inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,12 @@ describe("bundled-skill elision for routed models (devlog 260712 060)", () => {
const texts = userTexts(requestWithSkillTextBlock("claude-api", 500_000, undefined, "C:claude-api"));
expect(texts.some(t => t.length > 400_000)).toBe(true);
});

test("text-block carrier: oversized marker paths pass through without unbounded parsing", () => {
const oversizedDir = `/${"/".repeat(10_000)}claude-api`;
const texts = userTexts(requestWithSkillTextBlock("claude-api", 20_000, undefined, oversizedDir));
expect(texts.some(t => t.startsWith(`Base directory for this skill: ${oversizedDir}`))).toBe(true);
});
});

describe("ocx-route directive (devlog 072)", () => {
Expand Down
15 changes: 15 additions & 0 deletions tests/codex-integration/multi-agent-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1478,6 +1478,21 @@ describe("sanitizeEncryptedContentInPlace", () => {
const parts = (input[0] as { content: Array<Record<string, unknown>> }).content;
expect(parts[0]).toEqual({ type: "encrypted_content", encrypted_content: fernet });
});

test("mixed slots cap Fernet expansion", () => {
const payload = Array.from({ length: 1_000 }, () => fernetFixture()).join(".");
const input = [
{ type: "message", role: "user", content: [
{ type: "encrypted_content", encrypted_content: `preamble.${payload}` },
] },
];

expect(sanitizeEncryptedContentInPlace(input)).toBe(1);
const parts = (input[0] as { content: Array<Record<string, unknown>> }).content;
expect(parts.length).toBeLessThanOrEqual(129);
expect(parts.filter(part => part.type === "encrypted_content")).toHaveLength(64);
expect(parts.at(-1)?.type).toBe("input_text");
});
});

describe("spawn-message delivery (agent_message + encrypted slot)", () => {
Expand Down
32 changes: 32 additions & 0 deletions tests/providers/kiro/kiro-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,38 @@ describe("kiro adapter — parseStream", () => {
});
});

test("fallback HTTP errors stop reading oversized upstream bodies", async () => {
const chunk = new TextEncoder().encode("A".repeat(32 * 1024));
let pulls = 0;
let cancelled = false;
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
controller.enqueue(chunk);
},
cancel() {
cancelled = true;
},
}), {
status: 400,
headers: { "content-type": "text/plain" },
})) as typeof fetch;
const adapter = createKiroAdapter(provider);
await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool]));

const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf(
eventFrame({ content: "I am checking." }),
))));

expect(cancelled).toBe(true);
expect(pulls).toBeLessThan(10);
expect(events.at(-1)).toMatchObject({
type: "error",
status: 400,
retryable: false,
});
});

test("large first-attempt text stays charged through fallback construction and releases after parse", async () => {
const budget = createTranslatorBudget();
const firstText = "x".repeat(10 * 1024 * 1024);
Expand Down
Loading