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
8 changes: 4 additions & 4 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
break;
}
case "content_block_start": {
const block = data.content_block as { type: string; id?: string; name?: string; data?: string; thinking?: string } | undefined;
const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string } | undefined;
if (!block) break;
currentBlockType = block.type;
if (block.type === "thinking") {
Expand All @@ -1212,7 +1212,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
}
if (block.type === "tool_use") {
currentToolCallId = usableToolUseId(block.id);
currentToolCallName = toolNames.fromWire(block.name ?? "");
currentToolCallName = toolNames.fromWire(typeof block.name === "string" ? block.name : "");
currentToolCallJson = "";
budget.openCall(currentToolCallId);
yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
Expand Down Expand Up @@ -1426,7 +1426,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
}
}
}
const content = rawContent as { type: string; text?: string; id?: string; name?: string; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined;
const content = rawContent as { type: string; text?: string; id?: string; name?: unknown; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined;
if (content) {
for (const block of content) {
if (block.type === "text" && block.text) {
Expand All @@ -1442,7 +1442,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
events.push({ type: "redacted_thinking", data: block.data });
} else if (block.type === "tool_use") {
const id = usableToolUseId(block.id);
events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
events.push({ type: "tool_call_start", id, name: toolNames.fromWire(typeof block.name === "string" ? block.name : "") });
events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input, provider.anthropicEofTolerance === true) });
events.push({ type: "tool_call_end" });
}
Expand Down
16 changes: 12 additions & 4 deletions src/clients/config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,9 +985,15 @@ function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = fa
};
}

/** Do not let provider-controlled catalog text become an environment lookup. */
function containsEnvInterpolation(value: string): boolean {
return value.includes("${");
}

function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
const models: Record<string, HermesModelEntry> = {};
for (const model of normalizeExportModels(ctx.models)) {
if (containsEnvInterpolation(model.namespaced)) continue;
const declared = model.inputModalities;
models[model.namespaced] = declared && declared.length > 0
? { supports_vision: declared.includes("image") }
Expand All @@ -1009,15 +1015,17 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
}

function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig {
const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => {
const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).flatMap(model => {
const name = exportModelLabel(model);
if (containsEnvInterpolation(model.namespaced) || containsEnvInterpolation(name)) return [];
const context = authoritativeContextWindow(model.contextWindow);
const input = [...new Set(model.inputModalities?.filter(value => ["text", "image", "video", "audio"].includes(value)))];
return {
return [{
id: model.namespaced,
name: exportModelLabel(model),
name,
...(context !== undefined ? { contextWindow: context } : {}),
...(input.length > 0 ? { input } : {}),
};
}];
});
const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF);
return {
Expand Down
60 changes: 54 additions & 6 deletions src/github/star-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* invalidates the cache immediately, which is why the click path never has to
* wait for the TTL to see its own result.
*/
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, posix, win32 } from "node:path";
import { commandInvocation } from "../lib/win-exec";

export const STAR_REPO = "lidge-jun/opencodex";
Expand Down Expand Up @@ -54,13 +57,17 @@ export interface StarDeps {
*/
async function spawnGh(args: string[], timeoutMs: number): Promise<{ status: number | null } | null> {
try {
// On Windows `gh` is a `.cmd` shim, and a shell-less spawn of the bare name
// neither consults PATHEXT nor accepts a `.cmd` target. It does not fail
// fast either — it hangs until the timeout below fires, which is how these
// sidebar tests turned into 5s timeouts on windows-latest while passing
// everywhere else. `commandInvocation` is the resolver the CLI already uses.
const invocation = commandInvocation("gh", args);
const executable = resolveTrustedGhExecutable();
if (!executable) return null;
const trustedPath = trustedGhDirectories().join(delimiter);
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path"),
);
env.PATH = trustedPath;
const invocation = commandInvocation(executable, args);
const proc = Bun.spawn([invocation.file, ...invocation.args], {
cwd: homedir(),
env,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
Expand All @@ -79,6 +86,47 @@ async function spawnGh(args: string[], timeoutMs: number): Promise<{ status: num
}
}

/** Fixed install roots keep an automatically polled route from searching the project or caller-supplied PATH. */
function trustedGhDirectories(
platform: NodeJS.Platform = process.platform,
env: Record<string, string | undefined> = process.env,
): string[] {
if (platform !== "win32") {
return [
"/usr/local/bin",
"/usr/bin",
"/bin",
"/opt/homebrew/bin",
"/opt/local/bin",
"/home/linuxbrew/.linuxbrew/bin",
"/snap/bin",
"/run/current-system/sw/bin",
];
}
const directories: string[] = [];
for (const root of [env.ProgramFiles, env.ProgramW6432, env["ProgramFiles(x86)"]]) {
if (root && win32.isAbsolute(root)) directories.push(win32.join(root, "GitHub CLI"));
}
if (env.LOCALAPPDATA && win32.isAbsolute(env.LOCALAPPDATA)) {
directories.push(win32.join(env.LOCALAPPDATA, "Programs", "GitHub CLI"));
}
return directories;
}

export function resolveTrustedGhExecutable(
platform: NodeJS.Platform = process.platform,
env: Record<string, string | undefined> = process.env,
exists: (path: string) => boolean = existsSync,
): string | null {
const filename = platform === "win32" ? "gh.exe" : "gh";
const paths = platform === "win32" ? win32 : posix;
for (const directory of trustedGhDirectories(platform, env)) {
const candidate = paths.join(directory, filename);
if (paths.isAbsolute(candidate) && exists(candidate)) return candidate;
}
return null;
}

const productionDeps: StarDeps = { runGh: spawnGh, nowMs: () => Date.now() };
let defaultDeps = productionDeps;

Expand Down
14 changes: 9 additions & 5 deletions src/grok/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,15 @@ export function isDirectory(path: string): boolean {

/** INTERNAL API — see `ManagedRegion` above. Not a public fence-parsing surface. */
export function findManagedRegion(content: string): ManagedRegion | null {
const start = content.indexOf(BEGIN_MARKER);
if (start === -1) return null;
const endMarkerStart = content.indexOf(END_MARKER, start + BEGIN_MARKER.length);
if (endMarkerStart === -1) return { start, end: content.length, orphaned: true };
return { start, end: endMarkerStart + END_MARKER.length, orphaned: false };
const markerLine = (marker: string): RegExp =>
new RegExp(`^[ \\t]*${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[ \\t]*$`, "gm");
const begin = markerLine(BEGIN_MARKER).exec(content);
if (!begin) return null;
const end = markerLine(END_MARKER);
end.lastIndex = begin.index + begin[0].length;
const endMatch = end.exec(content);
if (!endMatch) return { start: begin.index, end: content.length, orphaned: true };
return { start: begin.index, end: endMatch.index + endMatch[0].length, orphaned: false };
}

/**
Expand Down
15 changes: 11 additions & 4 deletions src/grok/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,18 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
return { configPath, present: false, baseUrl: null, models: [] };
}

const begin = content.indexOf(BEGIN_MARKER);
const end = content.indexOf(END_MARKER, begin + 1);
if (begin < 0 || end < 0) return { configPath, present: false, baseUrl: null, models: [] };
// Line-anchored like findManagedRegion: marker-shaped text inside TOML string
// data (e.g. a provider-supplied model id) is not a fence boundary.
const markerLine = (marker: string): RegExp =>
new RegExp(`^[ \\t]*${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[ \\t]*$`, "gm");
const beginMatch = markerLine(BEGIN_MARKER).exec(content);
if (!beginMatch) return { configPath, present: false, baseUrl: null, models: [] };
const endRe = markerLine(END_MARKER);
endRe.lastIndex = beginMatch.index + beginMatch[0].length;
const endMatch = endRe.exec(content);
if (!endMatch) return { configPath, present: false, baseUrl: null, models: [] };

const region = content.slice(begin + BEGIN_MARKER.length, end);
const region = content.slice(beginMatch.index + beginMatch[0].length, endMatch.index);
const models: GrokStatusModel[] = [];
let baseUrl: string | null = null;
let current: GrokStatusModel | null = null;
Expand Down
31 changes: 31 additions & 0 deletions tests/adapters/anthropic/anthropic-stream-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ describe("anthropicMessagesUrl", () => {
});

describe("anthropic stream hardening", () => {
test("malformed escaped tool names degrade to an empty name", async () => {
const response = new Response([
"event: content_block_start\n",
'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"toolu_bad_name","name":{"bad":1}}}\n\n',
"event: content_block_stop\n",
'data: {"type":"content_block_stop"}\n\n',
"event: message_stop\n",
'data: {"type":"message_stop"}\n\n',
].join(""));
const escapedProvider = { ...provider, escapeBuiltinToolNames: true };
const events = await collect(createAnthropicAdapter(escapedProvider).parseStream(response));
expect(events.find(e => e.type === "tool_call_start")).toMatchObject({
type: "tool_call_start",
name: "",
});
expect(events.at(-1)?.type).toBe("done");
});

test("EOF after content without message_stop fails closed", async () => {
const response = new Response([
"event: content_block_start\n",
Expand Down Expand Up @@ -102,6 +120,19 @@ describe("anthropic stream hardening", () => {
});

describe("anthropic non-stream tool_use input", () => {
test("malformed escaped tool names degrade to an empty name", async () => {
const adapter = createAnthropicAdapter({ ...provider, escapeBuiltinToolNames: true });
const events = await adapter.parseResponse!(new Response(JSON.stringify({
content: [{ type: "tool_use", id: "toolu_bad_name", name: { bad: 1 }, input: {} }],
stop_reason: "tool_use",
})));
expect(events.find(e => e.type === "tool_call_start")).toMatchObject({
type: "tool_call_start",
name: "",
});
expect(events.at(-1)?.type).toBe("done");
});

test("parses string tool_use.input", async () => {
const adapter = createAnthropicAdapter(provider);
const events = await adapter.parseResponse!(new Response(JSON.stringify({
Expand Down
19 changes: 19 additions & 0 deletions tests/config/client-config-new-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ describe("openclaw", () => {
});
});

describe("interpolation-capable clients", () => {
test("omit provider-controlled model text that could expand an environment variable", () => {
const models: ExportModel[] = [
...MODELS,
{ namespaced: "evil/${SENSITIVE_ENV}", provider: "evil", id: "${SENSITIVE_ENV}" },
{ namespaced: "safe/id", provider: "safe", id: "id", displayName: "${SENSITIVE_ENV}" },
];
const maliciousContext = { ...ctx(), models };

const hermes = buildClientConfig("hermes", maliciousContext) as HermesGeneratedConfig;
expect(Object.keys(hermes.providers[OPENCODE_PROVIDER_ID]!.models)).not.toContain("evil/${SENSITIVE_ENV}");

const openclaw = buildClientConfig("openclaw", maliciousContext) as OpenclawGeneratedConfig;
expect(openclaw.models.providers[OPENCODE_PROVIDER_ID]!.models.map(model => model.id)).toEqual(
MODELS.map(model => model.namespaced).sort(),
);
});
});

describe("kimi", () => {
test("omits a model with no authoritative context window entirely", () => {
const doc = buildClientConfig("kimi", ctx()) as KimiGeneratedConfig;
Expand Down
22 changes: 22 additions & 0 deletions tests/providers/xai/grok-config-inject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ describe("Grok config injection", () => {
expect(content).toContain("[model.ocx-newer-model]");
});

test("treats managed markers in model metadata as TOML data", () => {
const configPath = join(grokHome, "config.toml");
const userContent = 'theme = "dark"\n';
writeFileSync(configPath, userContent, "utf8");
const hostileId = `provider/${BEGIN_MARKER} ${END_MARKER} stale-tail`;

injectGrokConfig(10100, [{ id: hostileId }], { grokHome });
const injected = readFileSync(configPath, "utf8");
expect(() => Bun.TOML.parse(injected)).not.toThrow();

const stripped = stripGrokConfig({ grokHome });
expect(stripped).toMatchObject({ ok: true, changed: true });
expect(readFileSync(configPath, "utf8")).toBe(userContent);

injectGrokConfig(10100, [{ id: hostileId }], { grokHome });
injectGrokConfig(10100, [{ id: "replacement" }], { grokHome });
const replaced = readFileSync(configPath, "utf8");
expect(replaced).not.toContain("stale-tail");
expect(replaced).toContain('model = "replacement"');
expect(() => Bun.TOML.parse(replaced)).not.toThrow();
});

test("emits a shared model_providers block and per-model references (grok 0.2.109+)", () => {
const block = buildGrokManagedBlock(10190, [{ id: "cursor/grok-4.5", contextWindow: 500_000 }]);
expect(block).toContain("[model_providers.opencodex]");
Expand Down
30 changes: 30 additions & 0 deletions tests/server/sidebar-star-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,41 @@ import {
getStarStatus,
invalidateStarStatusCache,
probeStarState,
resolveTrustedGhExecutable,
starRepository,
STAR_REPO,
type StarDeps,
} from "../../src/github/star-state";

describe("trusted gh resolution", () => {
test("does not search a caller-controlled POSIX PATH", () => {
const visited: string[] = [];
const resolved = resolveTrustedGhExecutable("linux", { PATH: "/workspace/untrusted:/tmp/bin" }, candidate => {
visited.push(candidate);
return candidate === "/usr/bin/gh";
});

expect(resolved).toBe("/usr/bin/gh");
expect(visited).not.toContain("/workspace/untrusted/gh");
expect(visited).not.toContain("/tmp/bin/gh");
});

test("only considers absolute Windows installation roots", () => {
const visited: string[] = [];
const resolved = resolveTrustedGhExecutable("win32", {
PATH: ".;C:\\workspace\\bin",
ProgramFiles: "C:\\Program Files",
LOCALAPPDATA: ".\\AppData\\Local",
}, candidate => {
visited.push(candidate);
return candidate === "C:\\Program Files\\GitHub CLI\\gh.exe";
});

expect(resolved).toBe("C:\\Program Files\\GitHub CLI\\gh.exe");
expect(visited).toEqual(["C:\\Program Files\\GitHub CLI\\gh.exe"]);
});
});

type GhCall = { args: string[]; timeoutMs: number };

const GH_HOSTNAME = "github.com";
Expand Down
Loading