Merge remote-tracking branch 'origin/main' into cs/codex-native-web-search-spec

# Conflicts:
#	src/agents/pi-embedded-runner/extra-params.ts
#	src/agents/pi-embedded-runner/openai-stream-wrappers.ts
This commit is contained in:
Christof Salis
2026-03-17 06:25:55 +01:00
926 changed files with 21587 additions and 13022 deletions

View File

@@ -51,4 +51,40 @@ describe("syncExternalCliCredentials", () => {
});
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
});
it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => {
const staleExpiry = Date.now() + 30 * 60_000;
const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000;
mocks.readCodexCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "openai-codex",
access: "new-access-token",
refresh: "new-refresh-token",
expires: freshExpiry,
accountId: "acct_456",
});
const store: AuthProfileStore = {
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "old-access-token",
refresh: "old-refresh-token",
expires: staleExpiry,
accountId: "acct_456",
},
},
};
const mutated = syncExternalCliCredentials(store);
expect(mutated).toBe(true);
expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
access: "new-access-token",
refresh: "new-refresh-token",
expires: freshExpiry,
});
});
});

View File

@@ -4,13 +4,12 @@ import {
readMiniMaxCliCredentialsCached,
} from "../cli-credentials.js";
import {
EXTERNAL_CLI_NEAR_EXPIRY_MS,
EXTERNAL_CLI_SYNC_TTL_MS,
QWEN_CLI_PROFILE_ID,
MINIMAX_CLI_PROFILE_ID,
log,
} from "./constants.js";
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
@@ -37,62 +36,33 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr
);
}
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
if (!cred) {
return false;
}
if (cred.type !== "oauth" && cred.type !== "token") {
return false;
}
if (
cred.provider !== "qwen-portal" &&
cred.provider !== "minimax-portal" &&
cred.provider !== "openai-codex"
) {
return false;
}
if (typeof cred.expires !== "number") {
return true;
}
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
}
/** Sync external CLI credentials into the store for a given provider. */
function syncExternalCliCredentialsForProvider(
store: AuthProfileStore,
profileId: string,
provider: string,
readCredentials: () => OAuthCredential | null,
now: number,
options: ExternalCliSyncOptions,
): boolean {
const existing = store.profiles[profileId];
const shouldSync =
!existing || existing.provider !== provider || !isExternalProfileFresh(existing, now);
const creds = shouldSync ? readCredentials() : null;
const creds = readCredentials();
if (!creds) {
return false;
}
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== provider ||
existingOAuth.expires <= now ||
creds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
store.profiles[profileId] = creds;
if (options.log !== false) {
log.info(`synced ${provider} credentials from external cli`, {
profileId,
expires: new Date(creds.expires).toISOString(),
});
}
return true;
if (shallowEqualOAuthCredentials(existingOAuth, creds)) {
return false;
}
return false;
store.profiles[profileId] = creds;
if (options.log !== false) {
log.info(`synced ${provider} credentials from external cli`, {
profileId,
expires: new Date(creds.expires).toISOString(),
});
}
return true;
}
/**
@@ -106,46 +76,24 @@ export function syncExternalCliCredentials(
options: ExternalCliSyncOptions = {},
): boolean {
let mutated = false;
const now = Date.now();
// Sync from Qwen Code CLI
const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID];
const shouldSyncQwen =
!existingQwen ||
existingQwen.provider !== "qwen-portal" ||
!isExternalProfileFresh(existingQwen, now);
const qwenCreds = shouldSyncQwen
? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null;
if (qwenCreds) {
const existing = store.profiles[QWEN_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "qwen-portal" ||
existingOAuth.expires <= now ||
qwenCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
mutated = true;
if (options.log !== false) {
log.info("synced qwen credentials from qwen cli", {
profileId: QWEN_CLI_PROFILE_ID,
expires: new Date(qwenCreds.expires).toISOString(),
});
}
}
if (
syncExternalCliCredentialsForProvider(
store,
QWEN_CLI_PROFILE_ID,
"qwen-portal",
() => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
options,
)
) {
mutated = true;
}
// Sync from MiniMax Portal CLI
if (
syncExternalCliCredentialsForProvider(
store,
MINIMAX_CLI_PROFILE_ID,
"minimax-portal",
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now,
options,
)
) {
@@ -157,7 +105,6 @@ export function syncExternalCliCredentials(
OPENAI_CODEX_DEFAULT_PROFILE_ID,
"openai-codex",
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now,
options,
)
) {

View File

@@ -46,6 +46,12 @@ async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
});
}
function createJwtWithExp(expSeconds: number): string {
const encode = (value: Record<string, unknown>) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
}
describe("cli credentials", () => {
beforeAll(async () => {
({
@@ -229,6 +235,7 @@ describe("cli credentials", () => {
it("reads Codex credentials from keychain when available", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
process.env.CODEX_HOME = tempHome;
const expSeconds = Math.floor(Date.parse("2026-03-23T00:48:49Z") / 1000);
const accountHash = "cli|";
@@ -238,7 +245,7 @@ describe("cli credentials", () => {
expect(cmd).toContain(accountHash);
return JSON.stringify({
tokens: {
access_token: "keychain-access",
access_token: createJwtWithExp(expSeconds),
refresh_token: "keychain-refresh",
},
last_refresh: "2026-01-01T00:00:00Z",
@@ -248,15 +255,17 @@ describe("cli credentials", () => {
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
expect(creds).toMatchObject({
access: "keychain-access",
access: createJwtWithExp(expSeconds),
refresh: "keychain-refresh",
provider: "openai-codex",
expires: expSeconds * 1000,
});
});
it("falls back to Codex auth.json when keychain is unavailable", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
process.env.CODEX_HOME = tempHome;
const expSeconds = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000);
execSyncMock.mockImplementation(() => {
throw new Error("not found");
});
@@ -267,7 +276,7 @@ describe("cli credentials", () => {
authPath,
JSON.stringify({
tokens: {
access_token: "file-access",
access_token: createJwtWithExp(expSeconds),
refresh_token: "file-refresh",
},
}),
@@ -277,9 +286,10 @@ describe("cli credentials", () => {
const creds = readCodexCliCredentials({ execSync: execSyncMock });
expect(creds).toMatchObject({
access: "file-access",
access: createJwtWithExp(expSeconds),
refresh: "file-refresh",
provider: "openai-codex",
expires: expSeconds * 1000,
});
});
});

View File

@@ -153,6 +153,22 @@ function computeCodexKeychainAccount(codexHome: string) {
return `cli|${hash.slice(0, 16)}`;
}
function decodeJwtExpiryMs(token: string): number | null {
const parts = token.split(".");
if (parts.length < 2) {
return null;
}
try {
const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
const payload = JSON.parse(payloadRaw) as { exp?: unknown };
return typeof payload.exp === "number" && Number.isFinite(payload.exp) && payload.exp > 0
? payload.exp * 1000
: null;
} catch {
return null;
}
}
function readCodexKeychainCredentials(options?: {
platform?: NodeJS.Platform;
execSync?: ExecSyncFn;
@@ -193,9 +209,10 @@ function readCodexKeychainCredentials(options?: {
typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number"
? new Date(lastRefreshRaw).getTime()
: Date.now();
const expires = Number.isFinite(lastRefresh)
const fallbackExpiry = Number.isFinite(lastRefresh)
? lastRefresh + 60 * 60 * 1000
: Date.now() + 60 * 60 * 1000;
const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry;
const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
log.info("read codex credentials from keychain", {
@@ -483,13 +500,14 @@ export function readCodexCliCredentials(options?: {
return null;
}
let expires: number;
let fallbackExpiry: number;
try {
const stat = fs.statSync(authPath);
expires = stat.mtimeMs + 60 * 60 * 1000;
fallbackExpiry = stat.mtimeMs + 60 * 60 * 1000;
} catch {
expires = Date.now() + 60 * 60 * 1000;
fallbackExpiry = Date.now() + 60 * 60 * 1000;
}
const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry;
return {
type: "oauth",

View File

@@ -5,43 +5,20 @@ import type { OpenClawConfig } from "../../config/config.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import type { CliBackendConfig } from "../../config/types.js";
import {
extractMcpServerMap,
loadEnabledBundleMcpConfig,
type BundleMcpConfig,
type BundleMcpServerConfig,
} from "../../plugins/bundle-mcp.js";
import { isRecord } from "../../utils.js";
type PreparedCliBundleMcpConfig = {
backend: CliBackendConfig;
cleanup?: () => Promise<void>;
};
function extractServerMap(raw: unknown): Record<string, BundleMcpServerConfig> {
if (!isRecord(raw)) {
return {};
}
const nested = isRecord(raw.mcpServers)
? raw.mcpServers
: isRecord(raw.servers)
? raw.servers
: raw;
if (!isRecord(nested)) {
return {};
}
const result: Record<string, BundleMcpServerConfig> = {};
for (const [serverName, serverRaw] of Object.entries(nested)) {
if (!isRecord(serverRaw)) {
continue;
}
result[serverName] = { ...serverRaw };
}
return result;
}
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
try {
const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown;
return { mcpServers: extractServerMap(raw) };
return { mcpServers: extractMcpServerMap(raw) };
} catch {
return { mcpServers: {} };
}

View File

@@ -0,0 +1,29 @@
import type { OpenClawConfig } from "../config/config.js";
import { normalizeConfiguredMcpServers } from "../config/mcp-config.js";
import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
export type EmbeddedPiMcpConfig = {
mcpServers: Record<string, BundleMcpServerConfig>;
diagnostics: BundleMcpDiagnostic[];
};
export function loadEmbeddedPiMcpConfig(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
}): EmbeddedPiMcpConfig {
const bundleMcp = loadEnabledBundleMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers);
return {
// OpenClaw config is the owner-managed layer, so it overrides bundle defaults.
mcpServers: {
...bundleMcp.config.mcpServers,
...configuredMcp,
},
diagnostics: bundleMcp.diagnostics,
};
}

79
src/agents/mcp-stdio.ts Normal file
View File

@@ -0,0 +1,79 @@
type StdioMcpServerLaunchConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
};
type StdioMcpServerLaunchResult =
| { ok: true; config: StdioMcpServerLaunchConfig }
| { ok: false; reason: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const entries = Object.entries(value)
.map(([key, entry]) => {
if (typeof entry === "string") {
return [key, entry] as const;
}
if (typeof entry === "number" || typeof entry === "boolean") {
return [key, String(entry)] as const;
}
return null;
})
.filter((entry): entry is readonly [string, string] => entry !== null);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length > 0 ? entries : [];
}
export function resolveStdioMcpServerLaunchConfig(raw: unknown): StdioMcpServerLaunchResult {
if (!isRecord(raw)) {
return { ok: false, reason: "server config must be an object" };
}
if (typeof raw.command !== "string" || raw.command.trim().length === 0) {
if (typeof raw.url === "string" && raw.url.trim().length > 0) {
return {
ok: false,
reason: "only stdio MCP servers are supported right now",
};
}
return { ok: false, reason: "its command is missing" };
}
const cwd =
typeof raw.cwd === "string" && raw.cwd.trim().length > 0
? raw.cwd
: typeof raw.workingDirectory === "string" && raw.workingDirectory.trim().length > 0
? raw.workingDirectory
: undefined;
return {
ok: true,
config: {
command: raw.command,
args: toStringArray(raw.args),
env: toStringRecord(raw.env),
cwd,
},
};
}
export function describeStdioMcpServerLaunchConfig(config: StdioMcpServerLaunchConfig): string {
const args =
Array.isArray(config.args) && config.args.length > 0 ? ` ${config.args.join(" ")}` : "";
const cwd = config.cwd ? ` (cwd=${config.cwd})` : "";
return `${config.command}${args}${cwd}`;
}
export type { StdioMcpServerLaunchConfig, StdioMcpServerLaunchResult };

View File

@@ -112,7 +112,8 @@ describe("model-selection", () => {
expect(normalizeProviderId("z-ai")).toBe("zai");
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
expect(normalizeProviderId("kimi-code")).toBe("kimi-coding");
expect(normalizeProviderId("kimi-code")).toBe("kimi");
expect(normalizeProviderId("kimi-coding")).toBe("kimi");
expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock");
expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock");
expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock");

View File

@@ -1,4 +1,4 @@
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.shared.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveAgentModelFallbackValues,

View File

@@ -74,8 +74,8 @@ describe("models-config merge helpers", () => {
headers: { "User-Agent": "claude-code/0.1.0" },
models: [
{
id: "k2p5",
name: "Kimi for Coding",
id: "kimi-code",
name: "Kimi Code",
input: ["text", "image"],
reasoning: true,
},
@@ -87,8 +87,8 @@ describe("models-config merge helpers", () => {
headers: { "X-Kimi-Tenant": "tenant-a" },
models: [
{
id: "k2p5",
name: "Kimi for Coding",
id: "kimi-code",
name: "Kimi Code",
input: ["text", "image"],
reasoning: true,
},

View File

@@ -1,14 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js";
import {
discoverHuggingfaceModels,
HUGGINGFACE_BASE_URL,
HUGGINGFACE_MODEL_CATALOG,
buildHuggingfaceModelDefinition,
} from "./huggingface-models.js";
import { discoverKilocodeModels } from "./kilocode-models.js";
import {
enrichOllamaModelsWithContext,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
@@ -24,9 +16,11 @@ import {
SELF_HOSTED_DEFAULT_MAX_TOKENS,
} from "./self-hosted-provider-defaults.js";
import { SGLANG_DEFAULT_BASE_URL, SGLANG_PROVIDER_LABEL } from "./sglang-defaults.js";
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
import { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js";
export { buildHuggingfaceProvider } from "../../extensions/huggingface/provider-catalog.js";
export { buildKilocodeProviderWithDiscovery } from "../../extensions/kilocode/provider-catalog.js";
export { buildVeniceProvider } from "../../extensions/venice/provider-catalog.js";
export { buildVercelAiGatewayProvider } from "../../extensions/vercel-ai-gateway/provider-catalog.js";
export { resolveOllamaApiBase } from "./ollama-models.js";
@@ -145,15 +139,6 @@ async function discoverOpenAICompatibleLocalModels(params: {
}
}
export async function buildVeniceProvider(): Promise<ProviderConfig> {
const models = await discoverVeniceModels();
return {
baseUrl: VENICE_BASE_URL,
api: "openai-completions",
models,
};
}
export async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
@@ -166,27 +151,6 @@ export async function buildOllamaProvider(
};
}
export async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise<ProviderConfig> {
const resolvedSecret = discoveryApiKey?.trim() ?? "";
const models =
resolvedSecret !== ""
? await discoverHuggingfaceModels(resolvedSecret)
: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);
return {
baseUrl: HUGGINGFACE_BASE_URL,
api: "openai-completions",
models,
};
}
export async function buildVercelAiGatewayProvider(): Promise<ProviderConfig> {
return {
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
api: "anthropic-messages",
models: await discoverVercelAiGatewayModels(),
};
}
export async function buildVllmProvider(params?: {
baseUrl?: string;
apiKey?: string;
@@ -220,16 +184,3 @@ export async function buildSglangProvider(params?: {
models,
};
}
/**
* Build the Kilocode provider with dynamic model discovery from the gateway
* API. Falls back to the static catalog on failure.
*/
export async function buildKilocodeProviderWithDiscovery(): Promise<ProviderConfig> {
const models = await discoverKilocodeModels();
return {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models,
};
}

View File

@@ -6,46 +6,47 @@ import { captureEnv } from "../test-utils/env.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { buildKimiCodingProvider } from "./models-config.providers.js";
describe("kimi-coding implicit provider (#22409)", () => {
it("should include kimi-coding when KIMI_API_KEY is configured", async () => {
describe("Kimi implicit provider (#22409)", () => {
it("should include Kimi when KIMI_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["kimi-coding"]).toBeDefined();
expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages");
expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/");
expect(providers?.kimi).toBeDefined();
expect(providers?.kimi?.api).toBe("anthropic-messages");
expect(providers?.kimi?.baseUrl).toBe("https://api.kimi.com/coding/");
} finally {
envSnapshot.restore();
}
});
it("should build kimi-coding provider with anthropic-messages API", () => {
it("should build Kimi provider with anthropic-messages API", () => {
const provider = buildKimiCodingProvider();
expect(provider.api).toBe("anthropic-messages");
expect(provider.baseUrl).toBe("https://api.kimi.com/coding/");
expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" });
expect(provider.models).toBeDefined();
expect(provider.models.length).toBeGreaterThan(0);
expect(provider.models[0].id).toBe("k2p5");
expect(provider.models[0].id).toBe("kimi-code");
expect(provider.models.some((model) => model.id === "k2p5")).toBe(true);
});
it("should not include kimi-coding when no API key is configured", async () => {
it("should not include Kimi when no API key is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
delete process.env.KIMI_API_KEY;
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["kimi-coding"]).toBeUndefined();
expect(providers?.kimi).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
it("uses explicit kimi-coding baseUrl when provided", async () => {
it("uses explicit legacy kimi-coding baseUrl when provided", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
process.env.KIMI_API_KEY = "test-key";
@@ -61,13 +62,13 @@ describe("kimi-coding implicit provider (#22409)", () => {
},
},
});
expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://kimi.example.test/coding/");
expect(providers?.kimi?.baseUrl).toBe("https://kimi.example.test/coding/");
} finally {
envSnapshot.restore();
}
});
it("merges explicit kimi-coding headers on top of the built-in user agent", async () => {
it("merges explicit legacy kimi-coding headers on top of the built-in user agent", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
process.env.KIMI_API_KEY = "test-key";
@@ -87,7 +88,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
},
},
});
expect(providers?.["kimi-coding"]?.headers).toEqual({
expect(providers?.kimi?.headers).toEqual({
"User-Agent": "custom-kimi-client/1.0",
"X-Kimi-Tenant": "tenant-a",
});

View File

@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
import {
MOONSHOT_BASE_URL as MOONSHOT_AI_BASE_URL,
MOONSHOT_CN_BASE_URL,
} from "../commands/onboard-auth.models.js";
} from "../plugins/provider-model-definitions.js";
import { captureEnv } from "../test-utils/env.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { applyNativeStreamingUsageCompat } from "./models-config.providers.js";

View File

@@ -1,551 +1,35 @@
import type { OpenClawConfig } from "../config/config.js";
import {
KILOCODE_BASE_URL,
KILOCODE_DEFAULT_CONTEXT_WINDOW,
KILOCODE_DEFAULT_COST,
KILOCODE_DEFAULT_MAX_TOKENS,
KILOCODE_MODEL_CATALOG,
} from "../providers/kilocode-shared.js";
import {
buildBytePlusModelDefinition,
BYTEPLUS_BASE_URL,
BYTEPLUS_MODEL_CATALOG,
BYTEPLUS_CODING_BASE_URL,
BYTEPLUS_CODING_MODEL_CATALOG,
} from "./byteplus-models.js";
import {
buildDoubaoModelDefinition,
DOUBAO_BASE_URL,
DOUBAO_MODEL_CATALOG,
DOUBAO_CODING_BASE_URL,
DOUBAO_CODING_MODEL_CATALOG,
} from "./doubao-models.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
SYNTHETIC_MODEL_CATALOG,
} from "./synthetic-models.js";
import {
TOGETHER_BASE_URL,
TOGETHER_MODEL_CATALOG,
buildTogetherModelDefinition,
} from "./together-models.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
type ProviderModelConfig = NonNullable<ProviderConfig["models"]>[number];
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5";
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
const MINIMAX_API_COST = {
input: 0.3,
output: 1.2,
cacheRead: 0.03,
cacheWrite: 0.12,
};
function buildMinimaxModel(params: {
id: string;
name: string;
reasoning: boolean;
input: ProviderModelConfig["input"];
}): ProviderModelConfig {
return {
id: params.id,
name: params.name,
reasoning: params.reasoning,
input: params.input,
cost: MINIMAX_API_COST,
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
};
}
function buildMinimaxTextModel(params: {
id: string;
name: string;
reasoning: boolean;
}): ProviderModelConfig {
return buildMinimaxModel({ ...params, input: ["text"] });
}
const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic";
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144;
const XIAOMI_DEFAULT_MAX_TOKENS = 8192;
const XIAOMI_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
const MOONSHOT_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/";
const KIMI_CODING_USER_AGENT = "claude-code/0.1.0";
const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5";
const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144;
const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768;
const KIMI_CODING_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1";
const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000;
const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192;
const QWEN_PORTAL_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const OPENROUTER_DEFAULT_MODEL_ID = "auto";
const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000;
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
const OPENROUTER_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
const QIANFAN_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export const MODELSTUDIO_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus";
const MODELSTUDIO_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray<ProviderModelConfig> = [
{
id: "qwen3.5-plus",
name: "qwen3.5-plus",
reasoning: false,
input: ["text", "image"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 1_000_000,
maxTokens: 65_536,
},
{
id: "qwen3-max-2026-01-23",
name: "qwen3-max-2026-01-23",
reasoning: false,
input: ["text"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 262_144,
maxTokens: 65_536,
},
{
id: "qwen3-coder-next",
name: "qwen3-coder-next",
reasoning: false,
input: ["text"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 262_144,
maxTokens: 65_536,
},
{
id: "qwen3-coder-plus",
name: "qwen3-coder-plus",
reasoning: false,
input: ["text"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 1_000_000,
maxTokens: 65_536,
},
{
id: "MiniMax-M2.5",
name: "MiniMax-M2.5",
reasoning: true,
input: ["text"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 1_000_000,
maxTokens: 65_536,
},
{
id: "glm-5",
name: "glm-5",
reasoning: false,
input: ["text"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 202_752,
maxTokens: 16_384,
},
{
id: "glm-4.7",
name: "glm-4.7",
reasoning: false,
input: ["text"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 202_752,
maxTokens: 16_384,
},
{
id: "kimi-k2.5",
name: "kimi-k2.5",
reasoning: false,
input: ["text", "image"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 262_144,
maxTokens: 32_768,
},
];
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct";
const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072;
const NVIDIA_DEFAULT_MAX_TOKENS = 4096;
const NVIDIA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
export function buildMinimaxProvider(): ProviderConfig {
return {
baseUrl: MINIMAX_PORTAL_BASE_URL,
api: "anthropic-messages",
authHeader: true,
models: [
buildMinimaxModel({
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
name: "MiniMax VL 01",
reasoning: false,
input: ["text", "image"],
}),
buildMinimaxTextModel({
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
reasoning: true,
}),
buildMinimaxTextModel({
id: "MiniMax-M2.5-highspeed",
name: "MiniMax M2.5 Highspeed",
reasoning: true,
}),
],
};
}
export function buildMinimaxPortalProvider(): ProviderConfig {
return {
baseUrl: MINIMAX_PORTAL_BASE_URL,
api: "anthropic-messages",
authHeader: true,
models: [
buildMinimaxModel({
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
name: "MiniMax VL 01",
reasoning: false,
input: ["text", "image"],
}),
buildMinimaxTextModel({
id: MINIMAX_DEFAULT_MODEL_ID,
name: "MiniMax M2.5",
reasoning: true,
}),
buildMinimaxTextModel({
id: "MiniMax-M2.5-highspeed",
name: "MiniMax M2.5 Highspeed",
reasoning: true,
}),
],
};
}
export function buildMoonshotProvider(): ProviderConfig {
return {
baseUrl: MOONSHOT_BASE_URL,
api: "openai-completions",
models: [
{
id: MOONSHOT_DEFAULT_MODEL_ID,
name: "Kimi K2.5",
reasoning: false,
input: ["text", "image"],
cost: MOONSHOT_DEFAULT_COST,
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildKimiCodingProvider(): ProviderConfig {
return {
baseUrl: KIMI_CODING_BASE_URL,
api: "anthropic-messages",
headers: {
"User-Agent": KIMI_CODING_USER_AGENT,
},
models: [
{
id: KIMI_CODING_DEFAULT_MODEL_ID,
name: "Kimi for Coding",
reasoning: true,
input: ["text", "image"],
cost: KIMI_CODING_DEFAULT_COST,
contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW,
maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildQwenPortalProvider(): ProviderConfig {
return {
baseUrl: QWEN_PORTAL_BASE_URL,
api: "openai-completions",
models: [
{
id: "coder-model",
name: "Qwen Coder",
reasoning: false,
input: ["text"],
cost: QWEN_PORTAL_DEFAULT_COST,
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
},
{
id: "vision-model",
name: "Qwen Vision",
reasoning: false,
input: ["text", "image"],
cost: QWEN_PORTAL_DEFAULT_COST,
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildSyntheticProvider(): ProviderConfig {
return {
baseUrl: SYNTHETIC_BASE_URL,
api: "anthropic-messages",
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
};
}
export function buildDoubaoProvider(): ProviderConfig {
return {
baseUrl: DOUBAO_BASE_URL,
api: "openai-completions",
models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition),
};
}
export function buildDoubaoCodingProvider(): ProviderConfig {
return {
baseUrl: DOUBAO_CODING_BASE_URL,
api: "openai-completions",
models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition),
};
}
export function buildBytePlusProvider(): ProviderConfig {
return {
baseUrl: BYTEPLUS_BASE_URL,
api: "openai-completions",
models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition),
};
}
export function buildBytePlusCodingProvider(): ProviderConfig {
return {
baseUrl: BYTEPLUS_CODING_BASE_URL,
api: "openai-completions",
models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition),
};
}
export function buildXiaomiProvider(): ProviderConfig {
return {
baseUrl: XIAOMI_BASE_URL,
api: "anthropic-messages",
models: [
{
id: XIAOMI_DEFAULT_MODEL_ID,
name: "Xiaomi MiMo V2 Flash",
reasoning: false,
input: ["text"],
cost: XIAOMI_DEFAULT_COST,
contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW,
maxTokens: XIAOMI_DEFAULT_MAX_TOKENS,
},
],
};
}
export function buildTogetherProvider(): ProviderConfig {
return {
baseUrl: TOGETHER_BASE_URL,
api: "openai-completions",
models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition),
};
}
export function buildOpenrouterProvider(): ProviderConfig {
return {
baseUrl: OPENROUTER_BASE_URL,
api: "openai-completions",
models: [
{
id: OPENROUTER_DEFAULT_MODEL_ID,
name: "OpenRouter Auto",
reasoning: false,
input: ["text", "image"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
},
{
id: "openrouter/hunter-alpha",
name: "Hunter Alpha",
reasoning: true,
input: ["text"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "openrouter/healer-alpha",
name: "Healer Alpha",
reasoning: true,
input: ["text", "image"],
cost: OPENROUTER_DEFAULT_COST,
contextWindow: 262144,
maxTokens: 65536,
},
],
};
}
export function buildOpenAICodexProvider(): ProviderConfig {
return {
baseUrl: OPENAI_CODEX_BASE_URL,
api: "openai-codex-responses",
models: [],
};
}
export function buildQianfanProvider(): ProviderConfig {
return {
baseUrl: QIANFAN_BASE_URL,
api: "openai-completions",
models: [
{
id: QIANFAN_DEFAULT_MODEL_ID,
name: "DEEPSEEK V3.2",
reasoning: true,
input: ["text"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
},
{
id: "ernie-5.0-thinking-preview",
name: "ERNIE-5.0-Thinking-Preview",
reasoning: true,
input: ["text", "image"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: 119000,
maxTokens: 64000,
},
],
};
}
export function buildModelStudioProvider(): ProviderConfig {
return {
baseUrl: MODELSTUDIO_BASE_URL,
api: "openai-completions",
models: MODELSTUDIO_MODEL_CATALOG.map((model) => ({ ...model })),
};
}
export function buildNvidiaProvider(): ProviderConfig {
return {
baseUrl: NVIDIA_BASE_URL,
api: "openai-completions",
models: [
{
id: NVIDIA_DEFAULT_MODEL_ID,
name: "NVIDIA Llama 3.1 Nemotron 70B Instruct",
reasoning: false,
input: ["text"],
cost: NVIDIA_DEFAULT_COST,
contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW,
maxTokens: NVIDIA_DEFAULT_MAX_TOKENS,
},
{
id: "meta/llama-3.3-70b-instruct",
name: "Meta Llama 3.3 70B Instruct",
reasoning: false,
input: ["text"],
cost: NVIDIA_DEFAULT_COST,
contextWindow: 131072,
maxTokens: 4096,
},
{
id: "nvidia/mistral-nemo-minitron-8b-8k-instruct",
name: "NVIDIA Mistral NeMo Minitron 8B Instruct",
reasoning: false,
input: ["text"],
cost: NVIDIA_DEFAULT_COST,
contextWindow: 8192,
maxTokens: 2048,
},
],
};
}
export function buildKilocodeProvider(): ProviderConfig {
return {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models: KILOCODE_MODEL_CATALOG.map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: model.input,
cost: KILOCODE_DEFAULT_COST,
contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW,
maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS,
})),
};
}
export {
buildBytePlusCodingProvider,
buildBytePlusProvider,
} from "../../extensions/byteplus/provider-catalog.js";
export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js";
export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js";
export {
buildMinimaxPortalProvider,
buildMinimaxProvider,
} from "../../extensions/minimax/provider-catalog.js";
export {
MODELSTUDIO_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_ID,
buildModelStudioProvider,
} from "../../extensions/modelstudio/provider-catalog.js";
export { buildMoonshotProvider } from "../../extensions/moonshot/provider-catalog.js";
export { buildNvidiaProvider } from "../../extensions/nvidia/provider-catalog.js";
export { buildOpenAICodexProvider } from "../../extensions/openai/openai-codex-catalog.js";
export { buildOpenrouterProvider } from "../../extensions/openrouter/provider-catalog.js";
export {
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
buildQianfanProvider,
} from "../../extensions/qianfan/provider-catalog.js";
export { buildQwenPortalProvider } from "../../extensions/qwen-portal-auth/provider-catalog.js";
export { buildSyntheticProvider } from "../../extensions/synthetic/provider-catalog.js";
export { buildTogetherProvider } from "../../extensions/together/provider-catalog.js";
export {
buildDoubaoCodingProvider,
buildDoubaoProvider,
} from "../../extensions/volcengine/provider-catalog.js";
export {
XIAOMI_DEFAULT_MODEL_ID,
buildXiaomiProvider,
} from "../../extensions/xiaomi/provider-catalog.js";

View File

@@ -1,3 +1,8 @@
import {
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
} from "../../extensions/qianfan/provider-catalog.js";
import { XIAOMI_DEFAULT_MODEL_ID } from "../../extensions/xiaomi/provider-catalog.js";
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { isRecord } from "../utils.js";
@@ -6,24 +11,23 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles
import { discoverBedrockModels } from "./bedrock-discovery.js";
import { normalizeGoogleModelId } from "./model-id-normalization.js";
import { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
import {
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
XIAOMI_DEFAULT_MODEL_ID,
} from "./models-config.providers.static.js";
export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js";
export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js";
export {
buildKimiCodingProvider,
buildKilocodeProvider,
buildNvidiaProvider,
buildModelStudioProvider,
buildQianfanProvider,
buildXiaomiProvider,
MODELSTUDIO_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_ID,
buildModelStudioProvider,
} from "../../extensions/modelstudio/provider-catalog.js";
export { buildNvidiaProvider } from "../../extensions/nvidia/provider-catalog.js";
export {
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
buildQianfanProvider,
} from "../../extensions/qianfan/provider-catalog.js";
export {
XIAOMI_DEFAULT_MODEL_ID,
} from "./models-config.providers.static.js";
buildXiaomiProvider,
} from "../../extensions/xiaomi/provider-catalog.js";
import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,

View File

@@ -117,6 +117,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
}
function isRefreshTokenReused(raw: string): boolean {
return /refresh_token_reused/i.test(raw);
}
function isInstructionsRequiredError(raw: string): boolean {
return /instructions are required/i.test(raw);
}
@@ -643,6 +647,15 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (rate limit)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&
isRefreshTokenReused(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (codex refresh token reused)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&

View File

@@ -167,6 +167,8 @@ function buildManager(opts?: ConstructorParameters<typeof OpenAIWebSocketManager
return new OpenAIWebSocketManager({
// Use faster backoff in tests to avoid slow timer waits
backoffDelaysMs: [10, 20, 40, 80, 160],
socketFactory: (url, options) =>
new MockWebSocket(url, options as Record<string, unknown>) as never,
...opts,
});
}
@@ -232,6 +234,22 @@ describe("OpenAIWebSocketManager", () => {
await connectPromise;
});
it("adds OpenClaw attribution headers on the native OpenAI websocket", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test-key");
const sock = lastSocket();
expect(sock.options).toMatchObject({
headers: expect.objectContaining({
originator: "openclaw",
"User-Agent": expect.stringMatching(/^openclaw\//),
}),
});
sock.simulateOpen();
await connectPromise;
});
it("resolves when the connection opens", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test");

View File

@@ -14,7 +14,8 @@
*/
import { EventEmitter } from "node:events";
import WebSocket from "ws";
import WebSocket, { type ClientOptions as WebSocketClientOptions } from "ws";
import { resolveProviderAttributionHeaders } from "./provider-attribution.js";
// ─────────────────────────────────────────────────────────────────────────────
// WebSocket Event Types (Server → Client)
@@ -251,6 +252,14 @@ const MAX_RETRIES = 5;
/** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s */
const BACKOFF_DELAYS_MS = [1000, 2000, 4000, 8000, 16000] as const;
function isOpenAIPublicWebSocketUrl(url: string): boolean {
try {
return new URL(url).hostname.toLowerCase() === "api.openai.com";
} catch {
return url.toLowerCase().includes("api.openai.com");
}
}
export interface OpenAIWebSocketManagerOptions {
/** Override the default WebSocket URL (useful for testing) */
url?: string;
@@ -258,6 +267,8 @@ export interface OpenAIWebSocketManagerOptions {
maxRetries?: number;
/** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */
backoffDelaysMs?: readonly number[];
/** Custom socket factory for tests. */
socketFactory?: (url: string, options: WebSocketClientOptions) => WebSocket;
}
type InternalEvents = {
@@ -297,12 +308,15 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
private readonly wsUrl: string;
private readonly maxRetries: number;
private readonly backoffDelaysMs: readonly number[];
private readonly socketFactory: (url: string, options: WebSocketClientOptions) => WebSocket;
constructor(options: OpenAIWebSocketManagerOptions = {}) {
super();
this.wsUrl = options.url ?? OPENAI_WS_URL;
this.maxRetries = options.maxRetries ?? MAX_RETRIES;
this.backoffDelaysMs = options.backoffDelaysMs ?? BACKOFF_DELAYS_MS;
this.socketFactory =
options.socketFactory ?? ((url, socketOptions) => new WebSocket(url, socketOptions));
}
// ─── Public API ────────────────────────────────────────────────────────────
@@ -382,10 +396,13 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
return;
}
const socket = new WebSocket(this.wsUrl, {
const socket = this.socketFactory(this.wsUrl, {
headers: {
Authorization: `Bearer ${this.apiKey}`,
"OpenAI-Beta": "responses-websocket=v1",
...(isOpenAIPublicWebSocketUrl(this.wsUrl)
? resolveProviderAttributionHeaders("openai")
: undefined),
},
});

View File

@@ -0,0 +1,184 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const tempDirs: string[] = [];
async function makeTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("createBundleMcpToolRuntime", () => {
it("loads bundle MCP tools and executes them", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
},
});
try {
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]);
const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined);
expect(result.content[0]).toMatchObject({
type: "text",
text: "FROM-BUNDLE",
});
expect(result.details).toEqual({
mcpServer: "bundleProbe",
mcpTool: "bundle_probe",
});
} finally {
await runtime.dispose();
}
});
it("skips bundle MCP tools that collide with existing tool names", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
},
reservedToolNames: ["bundle_probe"],
});
try {
expect(runtime.tools).toEqual([]);
} finally {
await runtime.dispose();
}
});
it("loads configured stdio MCP tools without a bundle", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const serverScriptPath = path.join(workspaceDir, "servers", "configured-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
mcp: {
servers: {
configuredProbe: {
command: "node",
args: [serverScriptPath],
env: {
BUNDLE_PROBE_TEXT: "FROM-CONFIG",
},
},
},
},
},
});
try {
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]);
const result = await runtime.tools[0].execute(
"call-configured-probe",
{},
undefined,
undefined,
);
expect(result.content[0]).toMatchObject({
type: "text",
text: "FROM-CONFIG",
});
expect(result.details).toEqual({
mcpServer: "configuredProbe",
mcpTool: "bundle_probe",
});
} finally {
await runtime.dispose();
}
});
});

View File

@@ -0,0 +1,225 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { logDebug, logWarn } from "../logger.js";
import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
import {
describeStdioMcpServerLaunchConfig,
resolveStdioMcpServerLaunchConfig,
} from "./mcp-stdio.js";
import type { AnyAgentTool } from "./tools/common.js";
type BundleMcpToolRuntime = {
tools: AnyAgentTool[];
dispose: () => Promise<void>;
};
type BundleMcpSession = {
serverName: string;
client: Client;
transport: StdioClientTransport;
detachStderr?: () => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function listAllTools(client: Client) {
const tools: Awaited<ReturnType<Client["listTools"]>>["tools"] = [];
let cursor: string | undefined;
do {
const page = await client.listTools(cursor ? { cursor } : undefined);
tools.push(...page.tools);
cursor = page.nextCursor;
} while (cursor);
return tools;
}
function toAgentToolResult(params: {
serverName: string;
toolName: string;
result: CallToolResult;
}): AgentToolResult<unknown> {
const content = Array.isArray(params.result.content)
? (params.result.content as AgentToolResult<unknown>["content"])
: [];
const normalizedContent: AgentToolResult<unknown>["content"] =
content.length > 0
? content
: params.result.structuredContent !== undefined
? [
{
type: "text",
text: JSON.stringify(params.result.structuredContent, null, 2),
},
]
: ([
{
type: "text",
text: JSON.stringify(
{
status: params.result.isError === true ? "error" : "ok",
server: params.serverName,
tool: params.toolName,
},
null,
2,
),
},
] as AgentToolResult<unknown>["content"]);
const details: Record<string, unknown> = {
mcpServer: params.serverName,
mcpTool: params.toolName,
};
if (params.result.structuredContent !== undefined) {
details.structuredContent = params.result.structuredContent;
}
if (params.result.isError === true) {
details.status = "error";
}
return {
content: normalizedContent,
details,
};
}
function attachStderrLogging(serverName: string, transport: StdioClientTransport) {
const stderr = transport.stderr;
if (!stderr || typeof stderr.on !== "function") {
return undefined;
}
const onData = (chunk: Buffer | string) => {
const message = String(chunk).trim();
if (!message) {
return;
}
for (const line of message.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed) {
logDebug(`bundle-mcp:${serverName}: ${trimmed}`);
}
}
};
stderr.on("data", onData);
return () => {
if (typeof stderr.off === "function") {
stderr.off("data", onData);
} else if (typeof stderr.removeListener === "function") {
stderr.removeListener("data", onData);
}
};
}
async function disposeSession(session: BundleMcpSession) {
session.detachStderr?.();
await session.client.close().catch(() => {});
await session.transport.close().catch(() => {});
}
export async function createBundleMcpToolRuntime(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
reservedToolNames?: Iterable<string>;
}): Promise<BundleMcpToolRuntime> {
const loaded = loadEmbeddedPiMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of loaded.diagnostics) {
logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
const reservedNames = new Set(
Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean),
);
const sessions: BundleMcpSession[] = [];
const tools: AnyAgentTool[] = [];
try {
for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) {
const launch = resolveStdioMcpServerLaunchConfig(rawServer);
if (!launch.ok) {
logWarn(`bundle-mcp: skipped server "${serverName}" because ${launch.reason}.`);
continue;
}
const launchConfig = launch.config;
const transport = new StdioClientTransport({
command: launchConfig.command,
args: launchConfig.args,
env: launchConfig.env,
cwd: launchConfig.cwd,
stderr: "pipe",
});
const client = new Client(
{
name: "openclaw-bundle-mcp",
version: "0.0.0",
},
{},
);
const session: BundleMcpSession = {
serverName,
client,
transport,
detachStderr: attachStderrLogging(serverName, transport),
};
try {
await client.connect(transport);
const listedTools = await listAllTools(client);
sessions.push(session);
for (const tool of listedTools) {
const normalizedName = tool.name.trim().toLowerCase();
if (!normalizedName) {
continue;
}
if (reservedNames.has(normalizedName)) {
logWarn(
`bundle-mcp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`,
);
continue;
}
reservedNames.add(normalizedName);
tools.push({
name: tool.name,
label: tool.title ?? tool.name,
description:
tool.description?.trim() ||
`Provided by bundle MCP server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}).`,
parameters: tool.inputSchema,
execute: async (_toolCallId, input) => {
const result = (await client.callTool({
name: tool.name,
arguments: isRecord(input) ? input : {},
})) as CallToolResult;
return toAgentToolResult({
serverName,
toolName: tool.name,
result,
});
},
});
}
} catch (error) {
logWarn(
`bundle-mcp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`,
);
await disposeSession(session);
}
}
return {
tools,
dispose: async () => {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
},
};
} catch (error) {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
throw error;
}
}

View File

@@ -35,6 +35,12 @@ describe("formatAssistantErrorText", () => {
);
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns context overflow for Ollama 'prompt too long' errors (#34005)", () => {
const msg = makeAssistantError(
'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}',
);
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns a reasoning-required message for mandatory reasoning endpoint errors", () => {
const msg = makeAssistantError(
"400 Reasoning is mandatory for this endpoint and cannot be disabled.",

View File

@@ -42,6 +42,14 @@ describe("sanitizeUserFacingText", () => {
);
});
it("sanitizes Ollama prompt-too-long payloads through the context-overflow path", () => {
const text =
'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}';
expect(sanitizeUserFacingText(text, { errorContext: true })).toContain(
"Context overflow: prompt too large for the model.",
);
});
it.each([
"Changelog note: we fixed false positives for `Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.` in 2026.2.9",
"nah it failed, hit a context overflow. the prompt was too large for the model. want me to retry it with a different approach?",

View File

@@ -97,6 +97,7 @@ export function isContextOverflowError(errorMessage?: string): boolean {
lower.includes("context length exceeded") ||
lower.includes("maximum context length") ||
lower.includes("prompt is too long") ||
lower.includes("prompt too long") ||
lower.includes("exceeds model context window") ||
lower.includes("model token limit") ||
(hasRequestSizeExceeds && hasContextWindow) ||
@@ -211,11 +212,12 @@ export function extractObservedOverflowTokenCount(errorMessage?: string): number
return undefined;
}
// Allow provider-wrapped API payloads such as "Ollama API error 400: {...}".
const ERROR_PAYLOAD_PREFIX_RE =
/^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i;
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i;
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
const ERROR_PREFIX_RE =
/^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i;
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;

View File

@@ -908,7 +908,7 @@ describe("applyExtraParamsToAgent", () => {
});
});
it("does not rewrite tool schema for kimi-coding (native Anthropic format)", () => {
it("does not rewrite tool schema for Kimi (native Anthropic format)", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
@@ -931,12 +931,12 @@ describe("applyExtraParamsToAgent", () => {
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "kimi-coding", "k2p5", undefined, "low");
applyExtraParamsToAgent(agent, undefined, "kimi", "kimi-code", undefined, "low");
const model = {
api: "anthropic-messages",
provider: "kimi-coding",
id: "k2p5",
provider: "kimi",
id: "kimi-code",
baseUrl: "https://api.kimi.com/coding/",
} as Model<"anthropic-messages">;
const context: Context = { messages: [] };
@@ -1160,7 +1160,8 @@ describe("applyExtraParamsToAgent", () => {
expect(calls).toHaveLength(1);
expect(calls[0]?.headers).toEqual({
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw",
"X-OpenRouter-Title": "OpenClaw",
"X-OpenRouter-Categories": "cli-agent",
"X-Custom": "1",
});
});

View File

@@ -0,0 +1,302 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import "./test-helpers/fast-coding-tools.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
cleanupEmbeddedPiRunnerTestWorkspace,
createEmbeddedPiRunnerOpenAiConfig,
createEmbeddedPiRunnerTestWorkspace,
type EmbeddedPiRunnerTestWorkspace,
immediateEnqueue,
} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js";
const E2E_TIMEOUT_MS = 20_000;
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
function createMockUsage(input: number, output: number) {
return {
input,
output,
cacheRead: 0,
cacheWrite: 0,
totalTokens: input + output,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
}
let streamCallCount = 0;
let observedContexts: Array<Array<{ role?: string; content?: unknown }>> = [];
async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
vi.mock("@mariozechner/pi-coding-agent", async () => {
return await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
"@mariozechner/pi-coding-agent",
);
});
vi.mock("@mariozechner/pi-ai", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
const buildToolUseMessage = (model: { api: string; provider: string; id: string }) => ({
role: "assistant" as const,
content: [
{
type: "toolCall" as const,
id: "tc-bundle-mcp-1",
name: "bundle_probe",
arguments: {},
},
],
stopReason: "toolUse" as const,
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 1),
timestamp: Date.now(),
});
const buildStopMessage = (
model: { api: string; provider: string; id: string },
text: string,
) => ({
role: "assistant" as const,
content: [{ type: "text" as const, text }],
stopReason: "stop" as const,
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 1),
timestamp: Date.now(),
});
return {
...actual,
complete: async (model: { api: string; provider: string; id: string }) => {
streamCallCount += 1;
return streamCallCount === 1
? buildToolUseMessage(model)
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
},
completeSimple: async (model: { api: string; provider: string; id: string }) => {
streamCallCount += 1;
return streamCallCount === 1
? buildToolUseMessage(model)
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
},
streamSimple: (
model: { api: string; provider: string; id: string },
context: { messages?: Array<{ role?: string; content?: unknown }> },
) => {
streamCallCount += 1;
const messages = (context.messages ?? []).map((message) => ({ ...message }));
observedContexts.push(messages);
const stream = actual.createAssistantMessageEventStream();
queueMicrotask(() => {
if (streamCallCount === 1) {
stream.push({
type: "done",
reason: "toolUse",
message: buildToolUseMessage(model),
});
stream.end();
return;
}
const toolResultText = messages.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
const sawBundleResult = toolResultText.some((text) => text.includes("FROM-BUNDLE"));
if (!sawBundleResult) {
stream.push({
type: "done",
reason: "error",
message: {
role: "assistant" as const,
content: [],
stopReason: "error" as const,
errorMessage: "bundle MCP tool result missing from context",
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 0),
timestamp: Date.now(),
},
});
stream.end();
return;
}
stream.push({
type: "done",
reason: "stop",
message: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE"),
});
stream.end();
});
return stream;
},
};
});
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
let e2eWorkspace: EmbeddedPiRunnerTestWorkspace | undefined;
let agentDir: string;
let workspaceDir: string;
beforeAll(async () => {
vi.useRealTimers();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
e2eWorkspace = await createEmbeddedPiRunnerTestWorkspace("openclaw-bundle-mcp-pi-");
({ agentDir, workspaceDir } = e2eWorkspace);
}, 180_000);
afterAll(async () => {
await cleanupEmbeddedPiRunnerTestWorkspace(e2eWorkspace);
e2eWorkspace = undefined;
});
const readSessionMessages = async (sessionFile: string) => {
const raw = await fs.readFile(sessionFile, "utf-8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map(
(line) =>
JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } },
)
.filter((entry) => entry.type === "message")
.map((entry) => entry.message) as Array<{ role?: string; content?: unknown }>;
};
describe("runEmbeddedPiAgent bundle MCP e2e", () => {
it(
"loads bundle MCP into Pi, executes the MCP tool, and includes the result in the follow-up turn",
{ timeout: E2E_TIMEOUT_MS },
async () => {
streamCallCount = 0;
observedContexts = [];
const sessionFile = path.join(workspaceDir, "session-bundle-mcp-e2e.jsonl");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const cfg = {
...createEmbeddedPiRunnerOpenAiConfig(["mock-bundle-mcp"]),
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
};
const result = await runEmbeddedPiAgent({
sessionId: "bundle-mcp-e2e",
sessionKey: "agent:test:bundle-mcp-e2e",
sessionFile,
workspaceDir,
config: cfg,
prompt: "Use the bundle MCP tool and report its result.",
provider: "openai",
model: "mock-bundle-mcp",
timeoutMs: 10_000,
agentDir,
runId: "run-bundle-mcp-e2e",
enqueue: immediateEnqueue,
});
expect(result.meta.stopReason).toBe("stop");
expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE");
expect(streamCallCount).toBe(2);
const followUpContext = observedContexts[1] ?? [];
const followUpTexts = followUpContext.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
expect(followUpTexts.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
const messages = await readSessionMessages(sessionFile);
const toolResults = messages.filter((message) => message?.role === "toolResult");
const toolResultText = toolResults.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
expect(toolResultText.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
},
);
});

View File

@@ -19,11 +19,11 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { generateSecureToken } from "../../infra/secure-random.js";
import { getMemorySearchManager } from "../../memory/index.js";
import { resolveSignalReactionLevel } from "../../plugin-sdk-internal/signal.js";
import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramReactionLevel,
} from "../../plugin-sdk-internal/telegram.js";
} from "../../plugin-sdk/telegram.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
@@ -53,6 +53,7 @@ import { supportsModelTools } from "../model-tool-support.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import { createConfiguredOllamaStreamFn } from "../ollama-stream.js";
import { resolveOwnerDisplaySetting } from "../owner-display.js";
import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js";
import {
ensureSessionHeader,
validateAnthropicTurns,
@@ -584,12 +585,24 @@ export async function compactEmbeddedPiSessionDirect(
modelContextWindowTokens: ctxInfo.tokens,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const toolsEnabled = supportsModelTools(runtimeModel);
const tools = sanitizeToolsForGoogle({
tools: supportsModelTools(runtimeModel) ? toolsRaw : [],
tools: toolsEnabled ? toolsRaw : [],
provider,
});
const allowedToolNames = collectAllowedToolNames({ tools });
logToolSchemasForGoogle({ tools, provider });
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: tools.map((tool) => tool.name),
})
: undefined;
const effectiveTools =
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
? [...tools, ...bundleMcpRuntime.tools]
: tools;
const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
logToolSchemasForGoogle({ tools: effectiveTools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
let runtimeCapabilities = runtimeChannel
@@ -706,7 +719,7 @@ export async function compactEmbeddedPiSessionDirect(
reactionGuidance,
messageToolHints,
sandboxInfo,
tools,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
@@ -769,7 +782,7 @@ export async function compactEmbeddedPiSessionDirect(
}
const { builtInTools, customTools } = splitSdkTools({
tools,
tools: effectiveTools,
sandboxEnabled: !!sandbox?.enabled,
});
@@ -1061,6 +1074,7 @@ export async function compactEmbeddedPiSessionDirect(
clearPendingOnTimeout: true,
});
session.dispose();
await bundleMcpRuntime?.dispose();
}
} finally {
await sessionLock.release();

View File

@@ -0,0 +1,110 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model } from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import { applyExtraParamsToAgent } from "./extra-params.js";
type CapturedCall = {
headers?: Record<string, string>;
};
function applyAndCapture(params: {
provider: string;
modelId: string;
baseUrl?: string;
callerHeaders?: Record<string, string>;
}): CapturedCall {
const captured: CapturedCall = {};
const baseStreamFn: StreamFn = (model, _context, options) => {
captured.headers = options?.headers;
options?.onPayload?.({}, model);
return createAssistantMessageEventStream();
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId);
const model = {
api: "openai-responses",
provider: params.provider,
id: params.modelId,
baseUrl: params.baseUrl,
} as Model<"openai-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, { headers: params.callerHeaders });
return captured;
}
describe("extra-params: OpenAI attribution", () => {
const envSnapshot = captureEnv(["OPENCLAW_VERSION"]);
afterEach(() => {
envSnapshot.restore();
});
it("injects originator and release-based user agent for native OpenAI", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai",
modelId: "gpt-5.4",
baseUrl: "https://api.openai.com/v1",
});
expect(headers).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
});
});
it("overrides caller-supplied OpenAI attribution headers", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai",
modelId: "gpt-5.4",
baseUrl: "https://api.openai.com/v1",
callerHeaders: {
originator: "spoofed",
"User-Agent": "spoofed/0.0.0",
"X-Custom": "1",
},
});
expect(headers).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
"X-Custom": "1",
});
});
it("does not inject attribution on non-native OpenAI-compatible base URLs", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai",
modelId: "gpt-5.4",
baseUrl: "https://proxy.example.com/v1",
});
expect(headers).toBeUndefined();
});
it("injects attribution for ChatGPT-backed OpenAI Codex traffic", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai-codex",
modelId: "gpt-5.4",
baseUrl: "https://chatgpt.com/backend-api",
});
expect(headers).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
});
});
});

View File

@@ -28,6 +28,7 @@ import {
import {
createCodexDefaultTransportWrapper,
createCodexNativeWebSearchWrapper,
createOpenAIAttributionHeadersWrapper,
createOpenAIDefaultTransportWrapper,
createOpenAIFastModeWrapper,
createOpenAIResponsesContextManagementWrapper,
@@ -266,7 +267,7 @@ function createParallelToolCallsWrapper(
/**
* Apply extra params (like temperature) to an agent's streamFn.
* Also adds OpenRouter app attribution headers when using the OpenRouter provider.
* Also applies verified provider-specific request wrappers, such as OpenRouter attribution.
*
* @internal Exported for testing
*/
@@ -306,12 +307,15 @@ export function applyExtraParamsToAgent(
},
}) ?? merged;
if (provider === "openai-codex") {
// Default Codex to WebSocket-first when nothing else specifies transport.
agent.streamFn = createCodexDefaultTransportWrapper(agent.streamFn);
} else if (provider === "openai") {
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
if (provider === "openai" || provider === "openai-codex") {
if (provider === "openai") {
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
} else {
// Default Codex to WebSocket-first when nothing else specifies transport.
agent.streamFn = createCodexDefaultTransportWrapper(agent.streamFn);
}
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
}
const wrappedStreamFn = createStreamFnWithExtraParams(
agent.streamFn,

View File

@@ -1129,13 +1129,13 @@ describe("resolveModel", () => {
it("lets provider config override registry-found kimi user agent headers", () => {
mockDiscoveredModel({
provider: "kimi-coding",
modelId: "k2p5",
provider: "kimi",
modelId: "kimi-code",
templateModel: {
...buildForwardCompatTemplate({
id: "k2p5",
name: "Kimi for Coding",
provider: "kimi-coding",
id: "kimi-code",
name: "Kimi Code",
provider: "kimi",
api: "anthropic-messages",
baseUrl: "https://api.kimi.com/coding/",
}),
@@ -1146,7 +1146,7 @@ describe("resolveModel", () => {
const cfg = {
models: {
providers: {
"kimi-coding": {
kimi: {
headers: {
"User-Agent": "custom-kimi-client/1.0",
"X-Kimi-Tenant": "tenant-a",
@@ -1156,8 +1156,9 @@ describe("resolveModel", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModel("kimi-coding", "k2p5", "/tmp/agent", cfg);
const result = resolveModel("kimi", "kimi-code", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.id).toBe("kimi-for-coding");
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
"User-Agent": "custom-kimi-client/1.0",
"X-Kimi-Tenant": "tenant-a",

View File

@@ -6,6 +6,7 @@ import {
patchCodexNativeWebSearchPayload,
resolveCodexNativeSearchActivation,
} from "../codex-native-web-search.js";
import { resolveProviderAttributionHeaders } from "../provider-attribution.js";
import { log } from "./logger.js";
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
@@ -47,6 +48,40 @@ function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean {
}
}
function isOpenAICodexBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return false;
}
try {
return new URL(baseUrl).hostname.toLowerCase() === "chatgpt.com";
} catch {
return baseUrl.toLowerCase().includes("chatgpt.com");
}
}
function shouldApplyOpenAIAttributionHeaders(model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
}): "openai" | "openai-codex" | undefined {
if (
model.provider === "openai" &&
(model.api === "openai-completions" || model.api === "openai-responses") &&
isOpenAIPublicApiBaseUrl(model.baseUrl)
) {
return "openai";
}
if (
model.provider === "openai-codex" &&
(model.api === "openai-codex-responses" || model.api === "openai-responses") &&
isOpenAICodexBaseUrl(model.baseUrl)
) {
return "openai-codex";
}
return undefined;
}
function shouldForceResponsesStore(model: {
api?: unknown;
provider?: unknown;
@@ -415,3 +450,22 @@ export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | und
return underlying(model, context, mergedOptions);
};
}
export function createOpenAIAttributionHeadersWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const attributionProvider = shouldApplyOpenAIAttributionHeaders(model);
if (!attributionProvider) {
return underlying(model, context, options);
}
return underlying(model, context, {
...options,
headers: {
...options?.headers,
...resolveProviderAttributionHeaders(attributionProvider),
},
});
};
}

View File

@@ -0,0 +1,38 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model } from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { createOpenRouterWrapper } from "./proxy-stream-wrappers.js";
describe("proxy stream wrappers", () => {
it("adds OpenRouter attribution headers to stream options", () => {
const calls: Array<{ headers?: Record<string, string> }> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
calls.push({
headers: options?.headers,
});
return createAssistantMessageEventStream();
};
const wrapped = createOpenRouterWrapper(baseStreamFn);
const model = {
api: "openai-completions",
provider: "openrouter",
id: "openrouter/auto",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void wrapped(model, context, { headers: { "X-Custom": "1" } });
expect(calls).toEqual([
{
headers: {
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
"X-OpenRouter-Categories": "cli-agent",
"X-Custom": "1",
},
},
]);
});
});

View File

@@ -1,11 +1,7 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
const OPENROUTER_APP_HEADERS: Record<string, string> = {
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw",
};
import { resolveProviderAttributionHeaders } from "../provider-attribution.js";
const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE";
const KILOCODE_FEATURE_DEFAULT = "openclaw";
const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE";
@@ -105,10 +101,11 @@ export function createOpenRouterWrapper(
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const onPayload = options?.onPayload;
const attributionHeaders = resolveProviderAttributionHeaders("openrouter");
return underlying(model, context, {
...options,
headers: {
...OPENROUTER_APP_HEADERS,
...attributionHeaders,
...options?.headers,
},
onPayload: (payload) => {

View File

@@ -16,11 +16,11 @@ import {
ensureGlobalUndiciStreamTimeouts,
} from "../../../infra/net/undici-global-dispatcher.js";
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
import { resolveSignalReactionLevel } from "../../../plugin-sdk-internal/signal.js";
import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramReactionLevel,
} from "../../../plugin-sdk-internal/telegram.js";
} from "../../../plugin-sdk/telegram.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import type {
PluginHookAgentContext,
@@ -59,6 +59,7 @@ import { supportsModelTools } from "../../model-tool-support.js";
import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js";
import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js";
import { resolveOwnerDisplaySetting } from "../../owner-display.js";
import { createBundleMcpToolRuntime } from "../../pi-bundle-mcp-tools.js";
import {
downgradeOpenAIFunctionCallReasoningPairs,
isCloudCodeAssistFormatError,
@@ -1009,7 +1010,7 @@ function wrapStreamRepairMalformedToolCallArguments(
if (!loggedRepairIndices.has(event.contentIndex)) {
loggedRepairIndices.add(event.contentIndex);
log.warn(
`repairing kimi-coding tool call arguments after ${repair.trailingSuffix.length} trailing chars`,
`repairing Kimi tool call arguments after ${repair.trailingSuffix.length} trailing chars`,
);
}
} else {
@@ -1064,7 +1065,7 @@ export function wrapStreamFnRepairMalformedToolCallArguments(baseFn: StreamFn):
}
function shouldRepairMalformedAnthropicToolCallArguments(provider?: string): boolean {
return normalizeProviderId(provider ?? "") === "kimi-coding";
return normalizeProviderId(provider ?? "") === "kimi";
}
// ---------------------------------------------------------------------------
@@ -1548,11 +1549,25 @@ export async function runEmbeddedAttempt(
provider: params.provider,
});
const clientTools = toolsEnabled ? params.clientTools : undefined;
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: [
...tools.map((tool) => tool.name),
...(clientTools?.map((tool) => tool.function.name) ?? []),
],
})
: undefined;
const effectiveTools =
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
? [...tools, ...bundleMcpRuntime.tools]
: tools;
const allowedToolNames = collectAllowedToolNames({
tools,
tools: effectiveTools,
clientTools,
});
logToolSchemasForGoogle({ tools, provider: params.provider });
logToolSchemasForGoogle({ tools: effectiveTools, provider: params.provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
@@ -1674,7 +1689,7 @@ export async function runEmbeddedAttempt(
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
@@ -1709,7 +1724,7 @@ export async function runEmbeddedAttempt(
bootstrapFiles: hookAdjustedBootstrapFiles,
injectedFiles: contextFiles,
skillsPrompt,
tools,
tools: effectiveTools,
});
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
let systemPromptText = systemPromptOverride();
@@ -1809,7 +1824,7 @@ export async function runEmbeddedAttempt(
const hookRunner = getGlobalHookRunner();
const { builtInTools, customTools } = splitSdkTools({
tools,
tools: effectiveTools,
sandboxEnabled: !!sandbox?.enabled,
});
@@ -2870,6 +2885,7 @@ export async function runEmbeddedAttempt(
});
session?.dispose();
releaseWsSession(params.sessionId);
await bundleMcpRuntime?.dispose();
await sessionLock.release();
}
} finally {

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import {
CONTEXT_LIMIT_TRUNCATION_NOTICE,
PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE,
PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER,
installToolResultContextGuard,
} from "./tool-result-context-guard.js";
@@ -268,4 +269,63 @@ describe("installToolResultContextGuard", () => {
expect(oldResult.details).toBeUndefined();
expect(newResult.details).toBeUndefined();
});
it("throws preemptive context overflow when context exceeds 90% after tool-result compaction", async () => {
const agent = makeGuardableAgent();
installToolResultContextGuard({
agent,
// contextBudgetChars = 1000 * 4 * 0.75 = 3000
// preemptiveOverflowChars = 1000 * 4 * 0.9 = 3600
contextWindowTokens: 1_000,
});
// Large user message (non-compactable) pushes context past 90% threshold.
const contextForNextCall = [makeUser("u".repeat(3_700)), makeToolResult("call_1", "small")];
await expect(
agent.transformContext?.(contextForNextCall, new AbortController().signal),
).rejects.toThrow(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE);
});
it("does not throw when context is under 90% after tool-result compaction", async () => {
const agent = makeGuardableAgent();
installToolResultContextGuard({
agent,
contextWindowTokens: 1_000,
});
// Context well under the 3600-char preemptive threshold.
const contextForNextCall = [makeUser("u".repeat(1_000)), makeToolResult("call_1", "small")];
await expect(
agent.transformContext?.(contextForNextCall, new AbortController().signal),
).resolves.not.toThrow();
});
it("compacts tool results before checking the preemptive overflow threshold", async () => {
const agent = makeGuardableAgent();
installToolResultContextGuard({
agent,
contextWindowTokens: 1_000,
});
// Large user message + large tool result. The guard should compact the tool
// result first, then check the overflow threshold. Even after compaction the
// user content alone pushes past 90%, so the overflow error fires.
const contextForNextCall = [
makeUser("u".repeat(3_700)),
makeToolResult("call_old", "x".repeat(2_000)),
];
await expect(
agent.transformContext?.(contextForNextCall, new AbortController().signal),
).rejects.toThrow(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE);
// Tool result should have been compacted before the overflow check.
const toolResultText = getToolResultText(contextForNextCall[1]);
expect(toolResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
});
});

View File

@@ -14,6 +14,9 @@ import {
// Keep a conservative input budget to absorb tokenizer variance and provider framing overhead.
const CONTEXT_INPUT_HEADROOM_RATIO = 0.75;
const SINGLE_TOOL_RESULT_CONTEXT_SHARE = 0.5;
// High-water mark: if context exceeds this ratio after tool-result compaction,
// trigger full session compaction via the existing overflow recovery cascade.
const PREEMPTIVE_OVERFLOW_RATIO = 0.9;
export const CONTEXT_LIMIT_TRUNCATION_NOTICE = "[truncated: output exceeded context limit]";
const CONTEXT_LIMIT_TRUNCATION_SUFFIX = `\n${CONTEXT_LIMIT_TRUNCATION_NOTICE}`;
@@ -21,6 +24,9 @@ const CONTEXT_LIMIT_TRUNCATION_SUFFIX = `\n${CONTEXT_LIMIT_TRUNCATION_NOTICE}`;
export const PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER =
"[compacted: tool output removed to free context]";
export const PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE =
"Preemptive context overflow: estimated context size exceeds safe threshold during tool loop";
type GuardableTransformContext = (
messages: AgentMessage[],
signal: AbortSignal,
@@ -196,6 +202,10 @@ export function installToolResultContextGuard(params: {
contextWindowTokens * TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE * SINGLE_TOOL_RESULT_CONTEXT_SHARE,
),
);
const preemptiveOverflowChars = Math.max(
contextBudgetChars,
Math.floor(contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE * PREEMPTIVE_OVERFLOW_RATIO),
);
// Agent.transformContext is private in pi-coding-agent, so access it via a
// narrow runtime view to keep callsites type-safe while preserving behavior.
@@ -214,6 +224,18 @@ export function installToolResultContextGuard(params: {
maxSingleToolResultChars,
});
// After tool-result compaction, check if context still exceeds the high-water mark.
// If it does, non-tool-result content dominates and only full LLM-based session
// compaction can reduce context size. Throwing a context overflow error triggers
// the existing overflow recovery cascade in run.ts.
const postEnforcementChars = estimateContextChars(
contextMessages,
createMessageCharEstimateCache(),
);
if (postEnforcementChars > preemptiveOverflowChars) {
throw new Error(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE);
}
return contextMessages;
}) as GuardableTransformContext;

View File

@@ -28,8 +28,6 @@ const mockSummarizeInStages = vi.mocked(compactionModule.summarizeInStages);
const {
collectToolFailures,
formatToolFailuresSection,
trimToolResultsForSummarization,
restoreOriginalToolResultsForKeptMessages,
splitPreservedRecentTurns,
formatPreservedTurnsSection,
buildCompactionStructureInstructions,
@@ -47,26 +45,6 @@ const {
SAFETY_MARGIN,
} = __testing;
function readTextBlocks(message: AgentMessage): string {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((block) => {
if (!block || typeof block !== "object") {
return "";
}
const text = (block as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.filter(Boolean)
.join("\n");
}
function stubSessionManager(): ExtensionContext["sessionManager"] {
const stub: ExtensionContext["sessionManager"] = {
getCwd: () => "/stub",
@@ -256,116 +234,6 @@ describe("compaction-safeguard tool failures", () => {
});
});
describe("compaction-safeguard toolResult trimming", () => {
it("truncates oversized tool results and compacts older entries to stay within budget", () => {
const messages: AgentMessage[] = Array.from({ length: 9 }, (_unused, index) => ({
role: "toolResult",
toolCallId: `call-${index}`,
toolName: "read",
content: [
{
type: "text",
text: `head-${index}\n${"x".repeat(25_000)}\ntail-${index}`,
},
],
timestamp: index + 1,
})) as AgentMessage[];
const trimmed = trimToolResultsForSummarization(messages);
expect(trimmed.stats.truncatedCount).toBe(9);
expect(trimmed.stats.compactedCount).toBe(1);
expect(readTextBlocks(trimmed.messages[0])).toBe("");
expect(trimmed.stats.afterChars).toBeLessThan(trimmed.stats.beforeChars);
expect(readTextBlocks(trimmed.messages[8])).toContain("head-8");
expect(readTextBlocks(trimmed.messages[8])).toContain(
"[...tool result truncated for compaction budget...]",
);
expect(readTextBlocks(trimmed.messages[8])).toContain("tail-8");
});
it("restores kept tool results after prune for both toolCallId and toolUseId", () => {
const originalMessages: AgentMessage[] = [
{ role: "user", content: "keep these tool results", timestamp: 1 },
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "original call payload" }],
timestamp: 2,
} as AgentMessage,
{
role: "toolResult",
toolUseId: "use-1",
toolName: "exec",
content: [{ type: "text", text: "original use payload" }],
timestamp: 3,
} as unknown as AgentMessage,
];
const prunedMessages: AgentMessage[] = [
originalMessages[0],
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "trimmed call payload" }],
timestamp: 2,
} as AgentMessage,
{
role: "toolResult",
toolUseId: "use-1",
toolName: "exec",
content: [{ type: "text", text: "trimmed use payload" }],
timestamp: 3,
} as unknown as AgentMessage,
];
const restored = restoreOriginalToolResultsForKeptMessages({
prunedMessages,
originalMessages,
});
expect(readTextBlocks(restored[1])).toBe("original call payload");
expect(readTextBlocks(restored[2])).toBe("original use payload");
});
it("extracts identifiers from the trimmed kept payloads after prune restore", () => {
const hiddenIdentifier = "DEADBEEF12345678";
const restored = restoreOriginalToolResultsForKeptMessages({
prunedMessages: [
{ role: "user", content: "recent ask", timestamp: 1 },
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "placeholder" }],
timestamp: 2,
} as AgentMessage,
],
originalMessages: [
{ role: "user", content: "recent ask", timestamp: 1 },
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [
{
type: "text",
text: `visible head ${"a".repeat(16_000)}${hiddenIdentifier}${"b".repeat(16_000)} visible tail`,
},
],
timestamp: 2,
} as AgentMessage,
],
});
const trimmed = trimToolResultsForSummarization(restored).messages;
const identifierSeedText = trimmed.map((message) => readTextBlocks(message)).join("\n");
expect(extractOpaqueIdentifiers(identifierSeedText)).not.toContain(hiddenIdentifier);
});
});
describe("computeAdaptiveChunkRatio", () => {
const CONTEXT_WINDOW = 200_000;

View File

@@ -407,179 +407,6 @@ function formatPreservedTurnsSection(messages: AgentMessage[]): string {
return `\n\n## Recent turns preserved verbatim\n${lines.join("\n")}`;
}
type ToolResultSummaryTrimStats = {
truncatedCount: number;
compactedCount: number;
beforeChars: number;
afterChars: number;
};
const COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS = 2_500;
const COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET = 20_000;
const COMPACTION_SUMMARY_TOOL_RESULT_TRUNCATION_NOTICE =
"[...tool result truncated for compaction budget...]";
const COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE =
"[tool result compacted due to global compaction budget]";
const COMPACTION_SUMMARY_TOOL_RESULT_NON_TEXT_NOTICE = "[non-text tool result content omitted]";
function getToolResultTextFromContent(content: unknown): string {
if (!Array.isArray(content)) {
return "";
}
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.length > 0) {
parts.push(text);
}
}
return parts.join("\n");
}
function hasNonTextToolResultContent(content: unknown): boolean {
if (!Array.isArray(content)) {
return false;
}
return content.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const t = (block as { type?: unknown }).type;
return t !== "text";
});
}
function replaceToolResultContentForSummary(msg: AgentMessage, text: string): AgentMessage {
return {
...(msg as unknown as Record<string, unknown>),
content: [{ type: "text", text }],
} as AgentMessage;
}
function trimToolResultsForSummarization(messages: AgentMessage[]): {
messages: AgentMessage[];
stats: ToolResultSummaryTrimStats;
} {
const next = [...messages];
let truncatedCount = 0;
let compactedCount = 0;
let beforeChars = 0;
for (let i = 0; i < next.length; i += 1) {
const msg = next[i];
if ((msg as { role?: unknown }).role !== "toolResult") {
continue;
}
const content = (msg as { content?: unknown }).content;
const text = getToolResultTextFromContent(content);
const hasNonText = hasNonTextToolResultContent(content);
beforeChars += text.length;
let normalized = text;
if (normalized.length === 0 && hasNonText) {
normalized = COMPACTION_SUMMARY_TOOL_RESULT_NON_TEXT_NOTICE;
}
if (normalized.length > COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS) {
const separator = `\n\n${COMPACTION_SUMMARY_TOOL_RESULT_TRUNCATION_NOTICE}\n\n`;
const available = Math.max(0, COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS - separator.length);
const tailBudget = Math.floor(available * 0.35);
const headBudget = Math.max(0, available - tailBudget);
const head = normalized.slice(0, headBudget);
const tail = tailBudget > 0 ? normalized.slice(-tailBudget) : "";
normalized = `${head}${separator}${tail}`;
truncatedCount += 1;
}
if (hasNonText || normalized !== text) {
next[i] = replaceToolResultContentForSummary(msg, normalized);
}
}
let runningChars = 0;
for (let i = next.length - 1; i >= 0; i -= 1) {
const msg = next[i];
if ((msg as { role?: unknown }).role !== "toolResult") {
continue;
}
const text = getToolResultTextFromContent((msg as { content?: unknown }).content);
if (runningChars + text.length <= COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET) {
runningChars += text.length;
continue;
}
const placeholderLen = COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE.length;
const remainingBudget = Math.max(
0,
COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET - runningChars,
);
const replacementText =
remainingBudget >= placeholderLen ? COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE : "";
next[i] = replaceToolResultContentForSummary(msg, replacementText);
runningChars += replacementText.length;
compactedCount += 1;
}
let afterChars = 0;
for (const msg of next) {
if ((msg as { role?: unknown }).role !== "toolResult") {
continue;
}
afterChars += getToolResultTextFromContent((msg as { content?: unknown }).content).length;
}
return {
messages: next,
stats: { truncatedCount, compactedCount, beforeChars, afterChars },
};
}
function getToolResultStableId(message: AgentMessage): string | null {
if ((message as { role?: unknown }).role !== "toolResult") {
return null;
}
const toolCallId = (message as { toolCallId?: unknown }).toolCallId;
if (typeof toolCallId === "string" && toolCallId.length > 0) {
return `call:${toolCallId}`;
}
const toolUseId = (message as { toolUseId?: unknown }).toolUseId;
if (typeof toolUseId === "string" && toolUseId.length > 0) {
return `use:${toolUseId}`;
}
return null;
}
function restoreOriginalToolResultsForKeptMessages(params: {
prunedMessages: AgentMessage[];
originalMessages: AgentMessage[];
}): AgentMessage[] {
const originalByStableId = new Map<string, AgentMessage[]>();
for (const message of params.originalMessages) {
const stableId = getToolResultStableId(message);
if (!stableId) {
continue;
}
const bucket = originalByStableId.get(stableId) ?? [];
bucket.push(message);
originalByStableId.set(stableId, bucket);
}
return params.prunedMessages.map((message) => {
const stableId = getToolResultStableId(message);
if (!stableId) {
return message;
}
const bucket = originalByStableId.get(stableId);
if (!bucket || bucket.length === 0) {
return message;
}
const restored = bucket.shift();
return restored ?? message;
});
}
function wrapUntrustedInstructionBlock(label: string, text: string): string {
return wrapUntrustedPromptDataBlock({
label,
@@ -928,18 +755,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
const modelContextWindow = resolveContextWindowTokens(model);
const contextWindowTokens = runtime?.contextWindowTokens ?? modelContextWindow;
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
const prefixTrimmedForBudget = trimToolResultsForSummarization(turnPrefixMessages);
if (
prefixTrimmedForBudget.stats.truncatedCount > 0 ||
prefixTrimmedForBudget.stats.compactedCount > 0
) {
log.warn(
`Compaction safeguard: pre-trimmed prefix toolResult payloads for budgeting ` +
`(truncated=${prefixTrimmedForBudget.stats.truncatedCount}, compacted=${prefixTrimmedForBudget.stats.compactedCount}, ` +
`chars=${prefixTrimmedForBudget.stats.beforeChars}->${prefixTrimmedForBudget.stats.afterChars})`,
);
}
const prefixMessagesForSummary = prefixTrimmedForBudget.messages;
let messagesToSummarize = preparation.messagesToSummarize;
const recentTurnsPreserve = resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve);
const qualityGuardEnabled = runtime?.qualityGuardEnabled ?? false;
@@ -959,44 +774,28 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
let droppedSummary: string | undefined;
if (tokensBefore !== undefined) {
const budgetTrimmedForSummary = trimToolResultsForSummarization(messagesToSummarize);
if (
budgetTrimmedForSummary.stats.truncatedCount > 0 ||
budgetTrimmedForSummary.stats.compactedCount > 0
) {
log.warn(
`Compaction safeguard: pre-trimmed toolResult payloads for budgeting ` +
`(truncated=${budgetTrimmedForSummary.stats.truncatedCount}, compacted=${budgetTrimmedForSummary.stats.compactedCount}, ` +
`chars=${budgetTrimmedForSummary.stats.beforeChars}->${budgetTrimmedForSummary.stats.afterChars})`,
);
}
const summarizableTokens =
estimateMessagesTokens(budgetTrimmedForSummary.messages) +
estimateMessagesTokens(prefixMessagesForSummary);
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
// Apply SAFETY_MARGIN so token underestimates don't trigger unnecessary pruning
const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN);
if (newContentTokens > maxHistoryTokens) {
const originalMessagesBeforePrune = messagesToSummarize;
const pruned = pruneHistoryForContextShare({
messages: budgetTrimmedForSummary.messages,
messages: messagesToSummarize,
maxContextTokens: contextWindowTokens,
maxHistoryShare,
parts: 2,
});
if (pruned.droppedChunks > 0) {
const historyRatio = (summarizableTokens / contextWindowTokens) * 100;
const newContentRatio = (newContentTokens / contextWindowTokens) * 100;
log.warn(
`Compaction safeguard: summarizable history uses ${historyRatio.toFixed(
`Compaction safeguard: new content uses ${newContentRatio.toFixed(
1,
)}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` +
`(${pruned.droppedMessages} messages) to fit history budget.`,
);
messagesToSummarize = restoreOriginalToolResultsForKeptMessages({
prunedMessages: pruned.messages,
originalMessages: originalMessagesBeforePrune,
});
messagesToSummarize = pruned.messages;
// Summarize dropped messages so context isn't lost
if (pruned.droppedMessagesList.length > 0) {
@@ -1010,19 +809,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
Math.floor(contextWindowTokens * droppedChunkRatio) -
SUMMARIZATION_OVERHEAD_TOKENS,
);
const droppedTrimmed = trimToolResultsForSummarization(pruned.droppedMessagesList);
if (
droppedTrimmed.stats.truncatedCount > 0 ||
droppedTrimmed.stats.compactedCount > 0
) {
log.warn(
`Compaction safeguard: trimmed dropped toolResult payloads before summarize ` +
`(truncated=${droppedTrimmed.stats.truncatedCount}, compacted=${droppedTrimmed.stats.compactedCount}, ` +
`chars=${droppedTrimmed.stats.beforeChars}->${droppedTrimmed.stats.afterChars})`,
);
}
droppedSummary = await summarizeInStages({
messages: droppedTrimmed.messages,
messages: pruned.droppedMessagesList,
model,
apiKey,
signal,
@@ -1054,21 +842,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
});
messagesToSummarize = summaryTargetMessages;
const preservedTurnsSection = formatPreservedTurnsSection(preservedRecentMessages);
const latestUserAsk = extractLatestUserAsk([
...messagesToSummarize,
...prefixMessagesForSummary,
]);
const summaryTrimmed = trimToolResultsForSummarization(messagesToSummarize);
if (summaryTrimmed.stats.truncatedCount > 0 || summaryTrimmed.stats.compactedCount > 0) {
log.warn(
`Compaction safeguard: trimmed toolResult payloads before summarize ` +
`(truncated=${summaryTrimmed.stats.truncatedCount}, compacted=${summaryTrimmed.stats.compactedCount}, ` +
`chars=${summaryTrimmed.stats.beforeChars}->${summaryTrimmed.stats.afterChars})`,
);
}
const identifierSourceMessages = [...summaryTrimmed.messages, ...prefixMessagesForSummary];
const identifierSeedText = identifierSourceMessages
const latestUserAsk = extractLatestUserAsk([...messagesToSummarize, ...turnPrefixMessages]);
const identifierSeedText = [...messagesToSummarize, ...turnPrefixMessages]
.slice(-10)
.map((message) => extractMessageText(message))
.filter(Boolean)
@@ -1078,7 +853,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
// Use adaptive chunk ratio based on message sizes, reserving headroom for
// the summarization prompt, system prompt, previous summary, and reasoning budget
// that generateSummary adds on top of the serialized conversation chunk.
const allMessages = [...summaryTrimmed.messages, ...prefixMessagesForSummary];
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
const maxChunkTokens = Math.max(
1,
@@ -1100,9 +875,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
let summaryWithPreservedTurns = "";
try {
const historySummary =
summaryTrimmed.messages.length > 0
messagesToSummarize.length > 0
? await summarizeInStages({
messages: summaryTrimmed.messages,
messages: messagesToSummarize,
model,
apiKey,
signal,
@@ -1116,9 +891,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
: buildStructuredFallbackSummary(effectivePreviousSummary, summarizationInstructions);
summaryWithoutPreservedTurns = historySummary;
if (preparation.isSplitTurn && prefixMessagesForSummary.length > 0) {
if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
const prefixSummary = await summarizeInStages({
messages: prefixMessagesForSummary,
messages: turnPrefixMessages,
model,
apiKey,
signal,
@@ -1218,8 +993,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
export const __testing = {
collectToolFailures,
formatToolFailuresSection,
trimToolResultsForSummarization,
restoreOriginalToolResultsForKeptMessages,
splitPreservedRecentTurns,
formatPreservedTurnsSection,
buildCompactionStructureInstructions,

View File

@@ -79,6 +79,106 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => {
expect(snapshot.compaction?.keepRecentTokens).toBe(64_000);
});
it("loads enabled bundle MCP servers into the Pi settings snapshot", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginRoot, "servers"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "claude-bundle",
}),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, ".mcp.json"),
JSON.stringify({
mcpServers: {
bundleProbe: {
command: "node",
args: ["./servers/probe.mjs"],
},
},
}),
"utf-8",
);
hoisted.loadPluginManifestRegistry.mockReturnValue(
buildRegistry({ pluginRoot, settingsFiles: [] }),
);
const snapshot = loadEnabledBundlePiSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.mcpServers).toEqual({
bundleProbe: {
command: "node",
args: [path.join(pluginRoot, "servers", "probe.mjs")],
cwd: pluginRoot,
},
});
});
it("lets top-level MCP config override bundle MCP defaults", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "claude-bundle",
}),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, ".mcp.json"),
JSON.stringify({
mcpServers: {
sharedServer: {
command: "node",
args: ["./servers/bundle.mjs"],
},
},
}),
"utf-8",
);
hoisted.loadPluginManifestRegistry.mockReturnValue(
buildRegistry({ pluginRoot, settingsFiles: [] }),
);
const snapshot = loadEnabledBundlePiSettingsSnapshot({
cwd: workspaceDir,
cfg: {
mcp: {
servers: {
sharedServer: {
url: "https://example.com/mcp",
},
},
},
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.mcpServers).toEqual({
sharedServer: {
url: "https://example.com/mcp",
},
});
});
it("ignores disabled bundle plugins", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");

View File

@@ -93,4 +93,34 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
expect(snapshot.compaction?.reserveTokens).toBe(32_000);
expect(snapshot.hideThinkingBlock).toBe(true);
});
it("lets project Pi settings override bundle MCP defaults", () => {
const snapshot = buildEmbeddedPiSettingsSnapshot({
globalSettings,
pluginSettings: {
mcpServers: {
bundleProbe: {
command: "node",
args: ["/plugins/probe.mjs"],
},
},
},
projectSettings: {
mcpServers: {
bundleProbe: {
command: "deno",
args: ["/workspace/probe.ts"],
},
},
},
policy: "sanitize",
});
expect(snapshot.mcpServers).toEqual({
bundleProbe: {
command: "deno",
args: ["/workspace/probe.ts"],
},
});
});
});

View File

@@ -8,6 +8,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isRecord } from "../utils.js";
import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js";
const log = createSubsystemLogger("embedded-pi-settings");
@@ -107,6 +108,19 @@ export function loadEnabledBundlePiSettingsSnapshot(params: {
}
}
const embeddedPiMcp = loadEmbeddedPiMcpConfig({
workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of embeddedPiMcp.diagnostics) {
log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
if (Object.keys(embeddedPiMcp.mcpServers).length > 0) {
snapshot = applyMergePatch(snapshot, {
mcpServers: embeddedPiMcp.mcpServers,
}) as PiSettingsSnapshot;
}
return snapshot;
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
listProviderAttributionPolicies,
resolveProviderAttributionHeaders,
resolveProviderAttributionIdentity,
resolveProviderAttributionPolicy,
} from "./provider-attribution.js";
describe("provider attribution", () => {
it("resolves the canonical OpenClaw product and runtime version", () => {
const identity = resolveProviderAttributionIdentity({
OPENCLAW_VERSION: "2026.3.99",
});
expect(identity).toEqual({
product: "OpenClaw",
version: "2026.3.99",
});
});
it("returns a documented OpenRouter attribution policy", () => {
const policy = resolveProviderAttributionPolicy("openrouter", {
OPENCLAW_VERSION: "2026.3.14",
});
expect(policy).toEqual({
provider: "openrouter",
enabledByDefault: true,
verification: "vendor-documented",
hook: "request-headers",
docsUrl: "https://openrouter.ai/docs/app-attribution",
reviewNote: "Documented app attribution headers. Verified in OpenClaw runtime wrapper.",
product: "OpenClaw",
version: "2026.3.14",
headers: {
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
"X-OpenRouter-Categories": "cli-agent",
},
});
});
it("normalizes aliases when resolving provider headers", () => {
expect(
resolveProviderAttributionHeaders("OpenRouter", {
OPENCLAW_VERSION: "2026.3.14",
}),
).toEqual({
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
"X-OpenRouter-Categories": "cli-agent",
});
});
it("returns a hidden-spec OpenAI attribution policy", () => {
expect(resolveProviderAttributionPolicy("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({
provider: "openai",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.",
product: "OpenClaw",
version: "2026.3.14",
headers: {
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
},
});
expect(resolveProviderAttributionHeaders("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
});
});
it("returns a hidden-spec OpenAI Codex attribution policy", () => {
expect(
resolveProviderAttributionPolicy("openai-codex", { OPENCLAW_VERSION: "2026.3.14" }),
).toEqual({
provider: "openai-codex",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.",
product: "OpenClaw",
version: "2026.3.14",
headers: {
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
},
});
});
it("lists the current attribution support matrix", () => {
expect(
listProviderAttributionPolicies({ OPENCLAW_VERSION: "2026.3.14" }).map((policy) => [
policy.provider,
policy.enabledByDefault,
policy.verification,
policy.hook,
]),
).toEqual([
["openrouter", true, "vendor-documented", "request-headers"],
["openai", true, "vendor-hidden-api-spec", "request-headers"],
["openai-codex", true, "vendor-hidden-api-spec", "request-headers"],
["anthropic", false, "vendor-sdk-hook-only", "default-headers"],
["google", false, "vendor-sdk-hook-only", "user-agent-extra"],
["groq", false, "vendor-sdk-hook-only", "default-headers"],
["mistral", false, "vendor-sdk-hook-only", "custom-user-agent"],
["together", false, "vendor-sdk-hook-only", "default-headers"],
]);
});
});

View File

@@ -0,0 +1,174 @@
import type { RuntimeVersionEnv } from "../version.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { normalizeProviderId } from "./model-selection.js";
export type ProviderAttributionVerification =
| "vendor-documented"
| "vendor-hidden-api-spec"
| "vendor-sdk-hook-only"
| "internal-runtime";
export type ProviderAttributionHook =
| "request-headers"
| "default-headers"
| "user-agent-extra"
| "custom-user-agent";
export type ProviderAttributionPolicy = {
provider: string;
enabledByDefault: boolean;
verification: ProviderAttributionVerification;
hook?: ProviderAttributionHook;
docsUrl?: string;
reviewNote?: string;
product: string;
version: string;
headers?: Record<string, string>;
};
export type ProviderAttributionIdentity = Pick<ProviderAttributionPolicy, "product" | "version">;
const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw";
const OPENCLAW_ATTRIBUTION_ORIGINATOR = "openclaw";
export function resolveProviderAttributionIdentity(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionIdentity {
return {
product: OPENCLAW_ATTRIBUTION_PRODUCT,
version: resolveRuntimeServiceVersion(env),
};
}
function buildOpenRouterAttributionPolicy(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy {
const identity = resolveProviderAttributionIdentity(env);
return {
provider: "openrouter",
enabledByDefault: true,
verification: "vendor-documented",
hook: "request-headers",
docsUrl: "https://openrouter.ai/docs/app-attribution",
reviewNote: "Documented app attribution headers. Verified in OpenClaw runtime wrapper.",
...identity,
headers: {
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": identity.product,
"X-OpenRouter-Categories": "cli-agent",
},
};
}
function buildOpenAIAttributionPolicy(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy {
const identity = resolveProviderAttributionIdentity(env);
return {
provider: "openai",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.",
...identity,
headers: {
originator: OPENCLAW_ATTRIBUTION_ORIGINATOR,
"User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`,
},
};
}
function buildOpenAICodexAttributionPolicy(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy {
const identity = resolveProviderAttributionIdentity(env);
return {
provider: "openai-codex",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.",
...identity,
headers: {
originator: OPENCLAW_ATTRIBUTION_ORIGINATOR,
"User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`,
},
};
}
function buildSdkHookOnlyPolicy(
provider: string,
hook: ProviderAttributionHook,
reviewNote: string,
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy {
return {
provider,
enabledByDefault: false,
verification: "vendor-sdk-hook-only",
hook,
reviewNote,
...resolveProviderAttributionIdentity(env),
};
}
export function listProviderAttributionPolicies(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy[] {
return [
buildOpenRouterAttributionPolicy(env),
buildOpenAIAttributionPolicy(env),
buildOpenAICodexAttributionPolicy(env),
buildSdkHookOnlyPolicy(
"anthropic",
"default-headers",
"Anthropic JS SDK exposes defaultHeaders, but app attribution is not yet verified.",
env,
),
buildSdkHookOnlyPolicy(
"google",
"user-agent-extra",
"Google GenAI JS SDK exposes userAgentExtra/httpOptions, but provider-side attribution is not yet verified.",
env,
),
buildSdkHookOnlyPolicy(
"groq",
"default-headers",
"Groq JS SDK exposes defaultHeaders, but app attribution is not yet verified.",
env,
),
buildSdkHookOnlyPolicy(
"mistral",
"custom-user-agent",
"Mistral JS SDK exposes a custom userAgent option, but app attribution is not yet verified.",
env,
),
buildSdkHookOnlyPolicy(
"together",
"default-headers",
"Together JS SDK exposes defaultHeaders, but app attribution is not yet verified.",
env,
),
];
}
export function resolveProviderAttributionPolicy(
provider?: string | null,
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy | undefined {
const normalized = normalizeProviderId(provider ?? "");
return listProviderAttributionPolicies(env).find((policy) => policy.provider === normalized);
}
export function resolveProviderAttributionHeaders(
provider?: string | null,
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): Record<string, string> | undefined {
const policy = resolveProviderAttributionPolicy(provider, env);
if (!policy?.enabledByDefault) {
return undefined;
}
return policy.headers;
}

View File

@@ -30,7 +30,7 @@ const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: str
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
};
case "kimi-coding":
case "kimi":
return {
preserveAnthropicThinkingSignatures: false,
};
@@ -84,9 +84,7 @@ describe("resolveProviderCapabilities", () => {
});
it("normalizes kimi aliases to the same capability set", () => {
expect(resolveProviderCapabilities("kimi-coding")).toEqual(
resolveProviderCapabilities("kimi-code"),
);
expect(resolveProviderCapabilities("kimi")).toEqual(resolveProviderCapabilities("kimi-code"));
expect(resolveProviderCapabilities("kimi-code")).toEqual({
anthropicToolSchemaMode: "native",
anthropicToolChoiceMode: "native",
@@ -131,7 +129,7 @@ describe("resolveProviderCapabilities", () => {
});
it("treats kimi aliases as native anthropic tool payload providers", () => {
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(false);
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi")).toBe(false);
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(false);
expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false);
});

View File

@@ -12,8 +12,8 @@ export function normalizeProviderId(provider: string): string {
if (normalized === "qwen") {
return "qwen-portal";
}
if (normalized === "kimi-code") {
return "kimi-coding";
if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") {
return "kimi";
}
if (normalized === "bedrock" || normalized === "aws-bedrock") {
return "amazon-bedrock";

View File

@@ -1,5 +1,5 @@
import path from "node:path";
import { CHANNEL_IDS } from "../../channels/registry.js";
import { CHANNEL_IDS } from "../../channels/ids.js";
import { STATE_DIR } from "../../config/paths.js";
export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes");

View File

@@ -1,7 +1,12 @@
import path from "node:path";
import { isPathInside } from "../../infra/path-guards.js";
import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js";
import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js";
import {
isPathInsideContainerRoot,
normalizeContainerPath as normalizeSandboxContainerPath,
} from "./path-utils.js";
import type { SandboxContext } from "./types.js";
type ResolvedRemotePath = SandboxResolvedPath & {
@@ -496,23 +501,10 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
}
function normalizeContainerPath(value: string): string {
const normalized = path.posix.normalize(value.trim() || "/");
const normalized = normalizeSandboxContainerPath(value.trim() || "/");
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
function isPathInsideContainerRoot(root: string, candidate: string): boolean {
const normalizedRoot = normalizeContainerPath(root);
const normalizedCandidate = normalizeContainerPath(candidate);
return (
normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`)
);
}
function isPathInside(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function toPosixRelative(root: string, candidate: string): string {
return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep);
}

View File

@@ -19,8 +19,8 @@ import {
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "../../plugin-sdk-internal/discord.js";
import { getPresence } from "../../plugin-sdk-internal/discord.js";
} from "../../plugin-sdk/discord.js";
import { getPresence } from "../../plugin-sdk/discord.js";
import {
type ActionGate,
jsonResult,

View File

@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import {
createThreadDiscord,
deleteMessageDiscord,
@@ -22,16 +23,9 @@ import {
sendStickerDiscord,
sendVoiceMessageDiscord,
unpinMessageDiscord,
} from "../../plugin-sdk-internal/discord.js";
import type {
DiscordSendComponents,
DiscordSendEmbeds,
} from "../../plugin-sdk-internal/discord.js";
import {
readDiscordComponentSpec,
resolveDiscordChannelId,
} from "../../plugin-sdk-internal/discord.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
} from "../../plugin-sdk/discord.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js";
import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
@@ -188,8 +182,8 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const permissions = accountId
? await fetchChannelPermissionsDiscord(channelId, { accountId })
: await fetchChannelPermissionsDiscord(channelId);
? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId })
: await fetchChannelPermissionsDiscord(channelId, cfgOptions);
return jsonResult({ ok: true, permissions });
}
case "fetchMessage": {
@@ -212,8 +206,8 @@ export async function handleDiscordMessagingAction(
);
}
const message = accountId
? await fetchMessageDiscord(channelId, messageId, { accountId })
: await fetchMessageDiscord(channelId, messageId);
? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId })
: await fetchMessageDiscord(channelId, messageId, cfgOptions);
return jsonResult({
ok: true,
message: normalizeMessage(message),
@@ -234,8 +228,8 @@ export async function handleDiscordMessagingAction(
around: readStringParam(params, "around"),
};
const messages = accountId
? await readMessagesDiscord(channelId, query, { accountId })
: await readMessagesDiscord(channelId, query);
? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId })
: await readMessagesDiscord(channelId, query, cfgOptions);
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
@@ -344,8 +338,8 @@ export async function handleDiscordMessagingAction(
required: true,
});
const message = accountId
? await editMessageDiscord(channelId, messageId, { content }, { accountId })
: await editMessageDiscord(channelId, messageId, { content });
? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId })
: await editMessageDiscord(channelId, messageId, { content }, cfgOptions);
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
@@ -357,9 +351,9 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await deleteMessageDiscord(channelId, messageId, { accountId });
await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
} else {
await deleteMessageDiscord(channelId, messageId);
await deleteMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@@ -381,8 +375,8 @@ export async function handleDiscordMessagingAction(
appliedTags: appliedTags ?? undefined,
};
const thread = accountId
? await createThreadDiscord(channelId, payload, { accountId })
: await createThreadDiscord(channelId, payload);
? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId })
: await createThreadDiscord(channelId, payload, cfgOptions);
return jsonResult({ ok: true, thread });
}
case "threadList": {
@@ -405,15 +399,18 @@ export async function handleDiscordMessagingAction(
before,
limit,
},
{ accountId },
{ ...cfgOptions, accountId },
)
: await listThreadsDiscord({
guildId,
channelId,
includeArchived,
before,
limit,
});
: await listThreadsDiscord(
{
guildId,
channelId,
includeArchived,
before,
limit,
},
cfgOptions,
);
return jsonResult({ ok: true, threads });
}
case "threadReply": {
@@ -444,9 +441,9 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await pinMessageDiscord(channelId, messageId, { accountId });
await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
} else {
await pinMessageDiscord(channelId, messageId);
await pinMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@@ -459,9 +456,9 @@ export async function handleDiscordMessagingAction(
required: true,
});
if (accountId) {
await unpinMessageDiscord(channelId, messageId, { accountId });
await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
} else {
await unpinMessageDiscord(channelId, messageId);
await unpinMessageDiscord(channelId, messageId, cfgOptions);
}
return jsonResult({ ok: true });
}
@@ -471,8 +468,8 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const pins = accountId
? await listPinsDiscord(channelId, { accountId })
: await listPinsDiscord(channelId);
? await listPinsDiscord(channelId, { ...cfgOptions, accountId })
: await listPinsDiscord(channelId, cfgOptions);
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
@@ -501,15 +498,18 @@ export async function handleDiscordMessagingAction(
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
},
{ accountId },
{ ...cfgOptions, accountId },
)
: await searchMessagesDiscord({
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
: await searchMessagesDiscord(
{
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
},
cfgOptions,
);
if (!results || typeof results !== "object") {
return jsonResult({ ok: true, results });
}

View File

@@ -5,7 +5,7 @@ import {
hasAnyGuildPermissionDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
} from "../../plugin-sdk-internal/discord.js";
} from "../../plugin-sdk/discord.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
import {
isDiscordModerationAction,

View File

@@ -1,7 +1,7 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import { getGateway } from "../../plugin-sdk-internal/discord.js";
import { getGateway } from "../../plugin-sdk/discord.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
const ACTIVITY_TYPE_MAP: Record<string, number> = {

View File

@@ -211,6 +211,24 @@ describe("handleDiscordMessagingAction", () => {
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("threads provided cfg into readMessages calls", async () => {
const cfg = {
channels: {
discord: {
token: "token",
},
},
} as OpenClawConfig;
await handleDiscordMessagingAction(
"readMessages",
{ channelId: "C1" },
enableAllActions,
{},
cfg,
);
expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg });
});
it("adds normalized timestamps to fetchMessage payloads", async () => {
fetchMessageDiscord.mockResolvedValueOnce({
id: "1",
@@ -229,6 +247,24 @@ describe("handleDiscordMessagingAction", () => {
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("threads provided cfg into fetchMessage calls", async () => {
const cfg = {
channels: {
discord: {
token: "token",
},
},
} as OpenClawConfig;
await handleDiscordMessagingAction(
"fetchMessage",
{ guildId: "G1", channelId: "C1", messageId: "M1" },
enableAllActions,
{},
cfg,
);
expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg });
});
it("adds normalized timestamps to listPins payloads", async () => {
listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
@@ -338,12 +374,17 @@ describe("handleDiscordMessagingAction", () => {
},
enableAllActions,
);
expect(createThreadDiscord).toHaveBeenCalledWith("C1", {
name: "Forum thread",
messageId: undefined,
autoArchiveMinutes: undefined,
content: "Initial forum post body",
});
expect(createThreadDiscord).toHaveBeenCalledWith(
"C1",
{
name: "Forum thread",
messageId: undefined,
autoArchiveMinutes: undefined,
content: "Initial forum post body",
appliedTags: undefined,
},
{},
);
});
});

View File

@@ -1,6 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { createDiscordActionGate } from "../../plugin-sdk-internal/discord.js";
import { createDiscordActionGate } from "../../plugin-sdk/discord.js";
import { readStringParam } from "./common.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";

View File

@@ -32,6 +32,7 @@ async function withTempAgentDir<T>(run: (agentDir: string) => Promise<T>): Promi
const ONE_PIXEL_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const ONE_PIXEL_GIF_B64 = "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=";
const ONE_PIXEL_JPEG_B64 = "QUJDRA==";
async function withTempWorkspacePng(
cb: (args: { workspaceDir: string; imagePath: string }) => Promise<void>,
@@ -736,10 +737,10 @@ describe("image tool MiniMax VLM routing", () => {
const res = await tool.execute("t1", {
prompt: "Compare these images.",
images: [`data:image/png;base64,${pngB64}`, `data:image/gif;base64,${ONE_PIXEL_GIF_B64}`],
images: [`data:image/png;base64,${pngB64}`, `data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`],
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
const details = res.details as
| {
images?: Array<{ image: string }>;
@@ -756,12 +757,12 @@ describe("image tool MiniMax VLM routing", () => {
image: `data:image/png;base64,${pngB64}`,
images: [
`data:image/png;base64,${pngB64}`,
`data:image/gif;base64,${ONE_PIXEL_GIF_B64}`,
`data:image/gif;base64,${ONE_PIXEL_GIF_B64}`,
`data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`,
`data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`,
],
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
const dedupedDetails = deduped.details as
| {
images?: Array<{ image: string }>;
@@ -776,7 +777,7 @@ describe("image tool MiniMax VLM routing", () => {
maxImages: 1,
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
expect(tooMany.details).toMatchObject({
error: "too_many_images",
count: 2,

View File

@@ -1,9 +1,10 @@
import { type Context, complete } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { getMediaUnderstandingProvider } from "../../media-understanding/providers/index.js";
import { buildProviderRegistry } from "../../media-understanding/runner.js";
import { loadWebMedia } from "../../plugin-sdk/web-media.js";
import { resolveUserPath } from "../../utils.js";
import { isMinimaxVlmModel, isMinimaxVlmProvider, minimaxUnderstandImage } from "../minimax-vlm.js";
import { isMinimaxVlmProvider } from "../minimax-vlm.js";
import {
coerceImageAssistantText,
coerceImageModelConfig,
@@ -14,17 +15,12 @@ import {
import {
applyImageModelConfigDefaults,
buildTextToolResult,
resolveModelFromRegistry,
resolveMediaToolLocalRoots,
resolveModelRuntimeApiKey,
resolvePromptAndModelOverride,
} from "./media-tool-shared.js";
import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js";
import {
createSandboxBridgeReadFile,
discoverAuthStorage,
discoverModels,
ensureOpenClawModelsJson,
resolveSandboxedBridgeMediaPath,
runWithImageModelFallback,
type AnyAgentTool,
@@ -168,27 +164,6 @@ function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undef
return undefined;
}
function buildImageContext(
prompt: string,
images: Array<{ base64: string; mimeType: string }>,
): Context {
const content: Array<
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
> = [{ type: "text", text: prompt }];
for (const img of images) {
content.push({ type: "image", data: img.base64, mimeType: img.mimeType });
}
return {
messages: [
{
role: "user",
content,
timestamp: Date.now(),
},
],
};
}
type ImageSandboxConfig = {
root: string;
bridge: SandboxFsBridge;
@@ -200,7 +175,7 @@ async function runImagePrompt(params: {
imageModelConfig: ImageModelConfig;
modelOverride?: string;
prompt: string;
images: Array<{ base64: string; mimeType: string }>;
images: Array<{ buffer: Buffer; mimeType: string }>;
}): Promise<{
text: string;
provider: string;
@@ -208,50 +183,75 @@ async function runImagePrompt(params: {
attempts: Array<{ provider: string; model: string; error: string }>;
}> {
const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.imageModelConfig);
await ensureOpenClawModelsJson(effectiveCfg, params.agentDir);
const authStorage = discoverAuthStorage(params.agentDir);
const modelRegistry = discoverModels(authStorage, params.agentDir);
const providerCfg: OpenClawConfig = effectiveCfg ?? {};
const providerRegistry = buildProviderRegistry(undefined, providerCfg);
const result = await runWithImageModelFallback({
cfg: effectiveCfg,
modelOverride: params.modelOverride,
run: async (provider, modelId) => {
const model = resolveModelFromRegistry({ modelRegistry, provider, modelId });
if (!model.input?.includes("image")) {
throw new Error(`Model does not support images: ${provider}/${modelId}`);
const imageProvider = getMediaUnderstandingProvider(provider, providerRegistry);
if (!imageProvider) {
throw new Error(`No media-understanding provider registered for ${provider}`);
}
const apiKey = await resolveModelRuntimeApiKey({
model,
cfg: effectiveCfg,
agentDir: params.agentDir,
authStorage,
});
// MiniMax VLM only supports a single image; use the first one.
if (isMinimaxVlmModel(model.provider, model.id)) {
const first = params.images[0];
const imageDataUrl = `data:${first.mimeType};base64,${first.base64}`;
const text = await minimaxUnderstandImage({
apiKey,
if (params.images.length > 1 && imageProvider.describeImages) {
const described = await imageProvider.describeImages({
images: params.images.map((image, index) => ({
buffer: image.buffer,
fileName: `image-${index + 1}`,
mime: image.mimeType,
})),
provider,
model: modelId,
prompt: params.prompt,
imageDataUrl,
modelBaseUrl: model.baseUrl,
maxTokens: resolveImageToolMaxTokens(undefined),
timeoutMs: 30_000,
cfg: providerCfg,
agentDir: params.agentDir,
});
return { text, provider: model.provider, model: model.id };
return { text: described.text, provider, model: described.model ?? modelId };
}
if (!imageProvider.describeImage) {
throw new Error(`Provider does not support image analysis: ${provider}`);
}
if (params.images.length === 1) {
const image = params.images[0];
const described = await imageProvider.describeImage({
buffer: image.buffer,
fileName: "image-1",
mime: image.mimeType,
provider,
model: modelId,
prompt: params.prompt,
maxTokens: resolveImageToolMaxTokens(undefined),
timeoutMs: 30_000,
cfg: providerCfg,
agentDir: params.agentDir,
});
return { text: described.text, provider, model: described.model ?? modelId };
}
const context = buildImageContext(params.prompt, params.images);
const message = await complete(model, context, {
apiKey,
maxTokens: resolveImageToolMaxTokens(model.maxTokens),
});
const text = coerceImageAssistantText({
message,
provider: model.provider,
model: model.id,
});
return { text, provider: model.provider, model: model.id };
const parts: string[] = [];
for (const [index, image] of params.images.entries()) {
const described = await imageProvider.describeImage({
buffer: image.buffer,
fileName: `image-${index + 1}`,
mime: image.mimeType,
provider,
model: modelId,
prompt: `${params.prompt}\n\nDescribe image ${index + 1} of ${params.images.length}.`,
maxTokens: resolveImageToolMaxTokens(undefined),
timeoutMs: 30_000,
cfg: providerCfg,
agentDir: params.agentDir,
});
parts.push(`Image ${index + 1}:\n${described.text.trim()}`);
}
return {
text: parts.join("\n\n").trim(),
provider,
model: modelId,
};
},
});
@@ -383,7 +383,7 @@ export function createImageTool(options?: {
// MARK: - Load and resolve each image
const loadedImages: Array<{
base64: string;
buffer: Buffer;
mimeType: string;
resolvedImage: string;
rewrittenFrom?: string;
@@ -469,9 +469,8 @@ export function createImageTool(options?: {
("contentType" in media && media.contentType) ||
("mimeType" in media && media.mimeType) ||
"image/png";
const base64 = media.buffer.toString("base64");
loadedImages.push({
base64,
buffer: media.buffer,
mimeType,
resolvedImage,
...(resolvedPathInfo.rewrittenFrom
@@ -487,7 +486,7 @@ export function createImageTool(options?: {
imageModelConfig,
modelOverride,
prompt: promptRaw,
images: loadedImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })),
images: loadedImages.map((img) => ({ buffer: img.buffer, mimeType: img.mimeType })),
});
const imageDetails =

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
@@ -8,6 +8,11 @@ import { createMessageTool } from "./message-tool.js";
const mocks = vi.hoisted(() => ({
runMessageAction: vi.fn(),
loadConfig: vi.fn(() => ({})),
resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({
resolvedConfig: config,
diagnostics: [],
})),
}));
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
@@ -20,6 +25,18 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
};
});
vi.mock("../../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,
};
});
vi.mock("../../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
}));
function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
@@ -41,6 +58,15 @@ function getActionEnum(properties: Record<string, unknown>) {
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
}
beforeEach(() => {
mocks.runMessageAction.mockReset();
mocks.loadConfig.mockReset().mockReturnValue({});
mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({
resolvedConfig: config,
diagnostics: [],
}));
});
function createChannelPlugin(params: {
id: string;
label: string;
@@ -101,6 +127,49 @@ async function executeSend(params: {
| undefined;
}
describe("message tool secret scoping", () => {
it("scopes command-time secret resolution to the selected channel/account", async () => {
mockSendResult({ channel: "discord", to: "discord:123" });
mocks.loadConfig.mockReturnValue({
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_TOKEN" },
accounts: {
ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } },
chat: { token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" } },
},
},
slack: {
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
},
},
});
const tool = createMessageTool({
currentChannelProvider: "discord",
agentAccountId: "ops",
});
await tool.execute("1", {
action: "send",
target: "channel:123",
message: "hi",
});
const secretResolveCall = mocks.resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as {
targetIds?: Set<string>;
allowedPaths?: Set<string>;
};
expect(secretResolveCall.targetIds).toBeInstanceOf(Set);
expect(
[...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.discord.")),
).toBe(true);
expect(secretResolveCall.allowedPaths).toEqual(
new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]),
);
});
});
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mockSendResult();

View File

@@ -12,7 +12,8 @@ import {
type ChannelMessageActionName,
} from "../../channels/plugins/types.js";
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js";
import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
@@ -820,19 +821,35 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}
}
const cfg = options?.config
? options.config
: (
await resolveCommandSecretRefsViaGateway({
config: loadConfig(),
commandName: "tools.message",
targetIds: getChannelsCommandSecretTargetIds(),
mode: "enforce_resolved",
})
).resolvedConfig;
const action = readStringParam(params, "action", {
required: true,
}) as ChannelMessageActionName;
let cfg = options?.config;
if (!cfg) {
const loadedRaw = loadConfig();
const scope = resolveMessageSecretScope({
channel: params.channel,
target: params.target,
targets: params.targets,
fallbackChannel: options?.currentChannelProvider,
accountId: params.accountId,
fallbackAccountId: agentAccountId,
});
const scopedTargets = getScopedChannelsCommandSecretTargets({
config: loadedRaw,
channel: scope.channel,
accountId: scope.accountId,
});
cfg = (
await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "tools.message",
targetIds: scopedTargets.targetIds,
...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}),
mode: "enforce_resolved",
})
).resolvedConfig;
}
const requireExplicitTarget = options?.requireExplicitTarget === true;
if (requireExplicitTarget && actionNeedsExplicitTarget(action)) {
const explicitTarget =

View File

@@ -1,5 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../plugin-sdk/account-resolution.js";
import {
deleteSlackMessage,
downloadSlackFile,
@@ -15,14 +16,13 @@ import {
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
} from "../../plugin-sdk-internal/slack.js";
} from "../../plugin-sdk/slack.js";
import {
parseSlackBlocksInput,
parseSlackTarget,
recordSlackThreadParticipation,
resolveSlackAccount,
resolveSlackChannelId,
} from "../../plugin-sdk-internal/slack.js";
} from "../../plugin-sdk/slack.js";
import { withNormalizedTimestamp } from "../date-time.js";
import {
createActionGate,

View File

@@ -1,17 +1,15 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import {
createTelegramActionGate,
resolveTelegramPollActionGateState,
} from "../../plugin-sdk-internal/telegram.js";
import type {
TelegramButtonStyle,
TelegramInlineButtons,
} from "../../plugin-sdk-internal/telegram.js";
} from "../../plugin-sdk/telegram.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramTargetChatType,
} from "../../plugin-sdk-internal/telegram.js";
} from "../../plugin-sdk/telegram.js";
import {
createForumTopicTelegram,
deleteMessageTelegram,
@@ -21,14 +19,13 @@ import {
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
} from "../../plugin-sdk-internal/telegram.js";
} from "../../plugin-sdk/telegram.js";
import {
getCacheStats,
resolveTelegramReactionLevel,
resolveTelegramToken,
searchStickers,
} from "../../plugin-sdk-internal/telegram.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
} from "../../plugin-sdk/telegram.js";
import { resolvePollMaxSelections } from "../../polls.js";
import {
jsonResult,

View File

@@ -1,148 +1,29 @@
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { logVerbose } from "../../globals.js";
import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { __testing as runtimeTesting } from "../../web-search/runtime.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
import { __testing as coreTesting } from "./web-search-core.js";
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
return undefined;
}
return search as WebSearchConfig;
}
function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean }): boolean {
if (typeof params.search?.enabled === "boolean") {
return params.search.enabled;
}
if (params.sandboxed) {
return true;
}
return true;
}
function readProviderEnvValue(envVars: string[]): string | undefined {
for (const envVar of envVars) {
const value = normalizeSecretInput(process.env[envVar]);
if (value) {
return value;
}
}
return undefined;
}
function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean {
const providers = resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
});
const provider = providers.find((entry) => entry.id === providerId);
if (!provider) {
return false;
}
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
const fromConfig = normalizeSecretInput(
normalizeResolvedSecretInputString({
value: rawValue,
path:
providerId === "brave"
? "tools.web.search.apiKey"
: `tools.web.search.${providerId}.apiKey`,
}),
);
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
}
function resolveSearchProvider(search?: WebSearchConfig): string {
const providers = resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
});
const raw =
search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase()
: "";
if (raw) {
const explicit = providers.find((provider) => provider.id === raw);
if (explicit) {
return explicit.id;
}
}
if (!raw) {
for (const provider of providers) {
if (!hasProviderCredential(provider.id, search)) {
continue;
}
logVerbose(
`web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
);
return provider.id;
}
}
return providers[0]?.id ?? "brave";
}
import {
__testing as coreTesting,
createWebSearchTool as createWebSearchToolCore,
} from "./web-search-core.js";
export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): AnyAgentTool | null {
const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
return null;
}
const providers = resolvePluginWebSearchProviders({
config: options?.config,
bundledAllowlistCompat: true,
});
if (providers.length === 0) {
return null;
}
const providerId =
options?.runtimeWebSearch?.selectedProvider ??
options?.runtimeWebSearch?.providerConfigured ??
resolveSearchProvider(search);
const provider =
providers.find((entry) => entry.id === providerId) ??
providers.find((entry) => entry.id === resolveSearchProvider(search)) ??
providers[0];
if (!provider) {
return null;
}
const definition = provider.createTool({
config: options?.config,
searchConfig: search as Record<string, unknown> | undefined,
runtimeMetadata: options?.runtimeWebSearch,
});
if (!definition) {
return null;
}
return {
label: "Web Search",
name: "web_search",
description: definition.description,
parameters: definition.parameters,
execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)),
};
return createWebSearchToolCore(options);
}
export const __testing = {
...coreTesting,
resolveSearchProvider,
resolveSearchProvider: (
search?: OpenClawConfig["tools"] extends infer Tools
? Tools extends { web?: infer Web }
? Web extends { search?: infer Search }
? Search
: undefined
: undefined
: undefined,
) => runtimeTesting.resolveWebSearchProviderId({ search }),
};

View File

@@ -325,9 +325,16 @@ describe("web_search provider proxy dispatch", () => {
describe("web_search perplexity Search API", () => {
const priorFetch = global.fetch;
const savedEnv = { ...process.env };
beforeEach(() => {
delete process.env.PERPLEXITY_API_KEY;
delete process.env.OPENROUTER_API_KEY;
});
afterEach(() => {
vi.unstubAllEnvs();
process.env = { ...savedEnv };
global.fetch = priorFetch;
webSearchTesting.SEARCH_CACHE.clear();
});
@@ -462,9 +469,16 @@ describe("web_search perplexity Search API", () => {
describe("web_search perplexity OpenRouter compatibility", () => {
const priorFetch = global.fetch;
const savedEnv = { ...process.env };
beforeEach(() => {
delete process.env.PERPLEXITY_API_KEY;
delete process.env.OPENROUTER_API_KEY;
});
afterEach(() => {
vi.unstubAllEnvs();
process.env = { ...savedEnv };
global.fetch = priorFetch;
webSearchTesting.SEARCH_CACHE.clear();
});

View File

@@ -1,6 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { sendReactionWhatsApp } from "../../plugin-sdk-internal/whatsapp.js";
import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js";

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveWhatsAppAccount } from "../../plugin-sdk-internal/whatsapp.js";
import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js";
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
import { ToolAuthorizationError } from "./common.js";

View File

@@ -114,16 +114,16 @@ describe("resolveTranscriptPolicy", () => {
preserveSignatures: false,
},
{
title: "kimi-coding provider",
provider: "kimi-coding",
modelId: "k2p5",
title: "Kimi provider",
provider: "kimi",
modelId: "kimi-code",
modelApi: "anthropic-messages" as const,
preserveSignatures: false,
},
{
title: "kimi-code alias",
provider: "kimi-code",
modelId: "k2p5",
modelId: "kimi-code",
modelApi: "anthropic-messages" as const,
preserveSignatures: false,
},

View File

@@ -51,6 +51,16 @@ const formatConfigArgs: CommandArgsFormatter = (values) =>
},
});
const formatMcpArgs: CommandArgsFormatter = (values) =>
formatActionArgs(values, {
formatKnownAction: (action, path) => {
if (action === "show" || action === "get") {
return path ? `${action} ${path}` : action;
}
return undefined;
},
});
const formatDebugArgs: CommandArgsFormatter = (values) =>
formatActionArgs(values, {
formatKnownAction: (action) => {
@@ -124,6 +134,7 @@ const formatExecArgs: CommandArgsFormatter = (values) => {
export const COMMAND_ARG_FORMATTERS: Record<string, CommandArgsFormatter> = {
config: formatConfigArgs,
mcp: formatMcpArgs,
debug: formatDebugArgs,
queue: formatQueueArgs,
exec: formatExecArgs,

View File

@@ -452,6 +452,34 @@ function buildChatCommands(): ChatCommandDefinition[] {
argsParsing: "none",
formatArgs: COMMAND_ARG_FORMATTERS.config,
}),
defineChatCommand({
key: "mcp",
nativeName: "mcp",
description: "Show or set embedded Pi MCP servers.",
textAlias: "/mcp",
category: "management",
args: [
{
name: "action",
description: "show | get | set | unset",
type: "string",
choices: ["show", "get", "set", "unset"],
},
{
name: "path",
description: "MCP server name",
type: "string",
},
{
name: "value",
description: "JSON config for set",
type: "string",
captureRemaining: true,
},
],
argsParsing: "none",
formatArgs: COMMAND_ARG_FORMATTERS.mcp,
}),
defineChatCommand({
key: "debug",
nativeName: "debug",

View File

@@ -99,6 +99,9 @@ export function isCommandEnabled(cfg: OpenClawConfig, commandKey: string): boole
if (commandKey === "config") {
return isCommandFlagEnabled(cfg, "config");
}
if (commandKey === "mcp") {
return isCommandFlagEnabled(cfg, "mcp");
}
if (commandKey === "debug") {
return isCommandFlagEnabled(cfg, "debug");
}

View File

@@ -3,7 +3,7 @@ import { logVerbose } from "../../globals.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
} from "../../plugin-sdk-internal/telegram.js";
} from "../../plugin-sdk/telegram.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";

View File

@@ -22,6 +22,7 @@ import {
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleMcpCommand } from "./commands-mcp.js";
import { handleModelsCommand } from "./commands-models.js";
import { handlePluginCommand } from "./commands-plugin.js";
import {
@@ -194,6 +195,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleWhoamiCommand,
handleSubagentsCommand,
handleAcpCommand,
handleMcpCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,

View File

@@ -0,0 +1,93 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { withTempHome } from "../../config/home-env.test-harness.js";
import { handleCommands } from "./commands-core.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
const tempDirs: string[] = [];
async function createWorkspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-mcp-"));
tempDirs.push(dir);
return dir;
}
function buildCfg(): OpenClawConfig {
return {
commands: {
text: true,
mcp: true,
},
};
}
describe("handleCommands /mcp", () => {
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("writes MCP config and shows it back", async () => {
await withTempHome("openclaw-command-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const setParams = buildCommandTestParams(
'/mcp set context7={"command":"uvx","args":["context7-mcp"]}',
buildCfg(),
undefined,
{ workspaceDir },
);
setParams.command.senderIsOwner = true;
const setResult = await handleCommands(setParams);
expect(setResult.reply?.text).toContain('MCP server "context7" saved');
const showParams = buildCommandTestParams("/mcp show context7", buildCfg(), undefined, {
workspaceDir,
});
showParams.command.senderIsOwner = true;
const showResult = await handleCommands(showParams);
expect(showResult.reply?.text).toContain('"command": "uvx"');
expect(showResult.reply?.text).toContain('"args": [');
});
});
it("rejects internal writes without operator.admin", async () => {
await withTempHome("openclaw-command-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const params = buildCommandTestParams(
'/mcp set context7={"command":"uvx","args":["context7-mcp"]}',
buildCfg(),
{
Provider: "webchat",
Surface: "webchat",
GatewayClientScopes: ["operator.write"],
},
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain("requires operator.admin");
});
});
it("accepts non-stdio MCP config at the config layer", async () => {
await withTempHome("openclaw-command-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const params = buildCommandTestParams(
'/mcp set remote={"url":"https://example.com/mcp"}',
buildCfg(),
undefined,
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain('MCP server "remote" saved');
});
});
});

View File

@@ -0,0 +1,134 @@
import {
listConfiguredMcpServers,
setConfiguredMcpServer,
unsetConfiguredMcpServer,
} from "../../config/mcp-config.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import {
rejectNonOwnerCommand,
rejectUnauthorizedCommand,
requireCommandFlagEnabled,
requireGatewayClientScopeForInternalChannel,
} from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
import { parseMcpCommand } from "./mcp-commands.js";
function renderJsonBlock(label: string, value: unknown): string {
return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
}
export const handleMcpCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const mcpCommand = parseMcpCommand(params.command.commandBodyNormalized);
if (!mcpCommand) {
return null;
}
const unauthorized = rejectUnauthorizedCommand(params, "/mcp");
if (unauthorized) {
return unauthorized;
}
const allowInternalReadOnlyShow =
mcpCommand.action === "show" && isInternalMessageChannel(params.command.channel);
const nonOwner = allowInternalReadOnlyShow ? null : rejectNonOwnerCommand(params, "/mcp");
if (nonOwner) {
return nonOwner;
}
const disabled = requireCommandFlagEnabled(params.cfg, {
label: "/mcp",
configKey: "mcp",
});
if (disabled) {
return disabled;
}
if (mcpCommand.action === "error") {
return {
shouldContinue: false,
reply: { text: `⚠️ ${mcpCommand.message}` },
};
}
if (mcpCommand.action === "show") {
const loaded = await listConfiguredMcpServers();
if (!loaded.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${loaded.error}` },
};
}
if (mcpCommand.name) {
const server = loaded.mcpServers[mcpCommand.name];
if (!server) {
return {
shouldContinue: false,
reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${loaded.path}.` },
};
}
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(`🔌 MCP server "${mcpCommand.name}" (${loaded.path})`, server),
},
};
}
if (Object.keys(loaded.mcpServers).length === 0) {
return {
shouldContinue: false,
reply: { text: `🔌 No MCP servers configured in ${loaded.path}.` },
};
}
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(`🔌 MCP servers (${loaded.path})`, loaded.mcpServers),
},
};
}
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/mcp write",
allowedScopes: ["operator.admin"],
missingText: "❌ /mcp set|unset requires operator.admin for gateway clients.",
});
if (missingAdminScope) {
return missingAdminScope;
}
if (mcpCommand.action === "set") {
const result = await setConfiguredMcpServer({
name: mcpCommand.name,
server: mcpCommand.value,
});
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error}` },
};
}
return {
shouldContinue: false,
reply: {
text: `🔌 MCP server "${mcpCommand.name}" saved to ${result.path}.`,
},
};
}
const result = await unsetConfiguredMcpServer({ name: mcpCommand.name });
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error}` },
};
}
if (!result.removed) {
return {
shouldContinue: false,
reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${result.path}.` },
};
}
return {
shouldContinue: false,
reply: { text: `🔌 MCP server "${mcpCommand.name}" removed from ${result.path}.` },
};
};

View File

@@ -16,7 +16,7 @@ import {
calculateTotalPages,
getModelsPageSize,
type ProviderInfo,
} from "../../plugin-sdk-internal/telegram.js";
} from "../../plugin-sdk/telegram.js";
import type { ReplyPayload } from "../types.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";

View File

@@ -1,4 +1,5 @@
import { logVerbose } from "../../globals.js";
import { listSpeechProviders, normalizeSpeechProviderId } from "../../tts/provider-registry.js";
import {
getLastTtsAttempt,
getTtsMaxLength,
@@ -54,7 +55,7 @@ function ttsUsage(): ReplyPayload {
`• /tts summary [on|off] — View/change auto-summary\n` +
`• /tts audio <text> — Generate audio from text\n\n` +
`**Providers:**\n` +
`edge — Free, fast (default)\n` +
`microsoft — Microsoft Edge-backed speech (default fallback)\n` +
`• openai — High quality (requires API key)\n` +
`• elevenlabs — Premium voices (requires API key)\n\n` +
`**Text Limit (default: 1500, max: 4096):**\n` +
@@ -62,7 +63,7 @@ function ttsUsage(): ReplyPayload {
`• Summary ON: AI summarizes, then generates audio\n` +
`• Summary OFF: Truncates text, then generates audio\n\n` +
`**Examples:**\n` +
`/tts provider edge\n` +
`/tts provider microsoft\n` +
`/tts limit 2000\n` +
`/tts audio Hello, this is a test!`,
};
@@ -161,7 +162,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (!args.trim()) {
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge");
const hasMicrosoft = isTtsProviderConfigured(config, "microsoft", params.cfg);
return {
shouldContinue: false,
reply: {
@@ -170,21 +171,23 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
`Primary: ${currentProvider}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
`Usage: /tts provider openai | elevenlabs | edge`,
`Microsoft enabled: ${hasMicrosoft ? "✅" : "❌"}\n` +
`Usage: /tts provider openai | elevenlabs | microsoft`,
},
};
}
const requested = args.trim().toLowerCase();
if (requested !== "openai" && requested !== "elevenlabs" && requested !== "edge") {
const knownProviders = new Set(listSpeechProviders(params.cfg).map((provider) => provider.id));
if (requested !== "edge" && !knownProviders.has(requested)) {
return { shouldContinue: false, reply: ttsUsage() };
}
const nextProvider = normalizeSpeechProviderId(requested) ?? requested;
setTtsProvider(prefsPath, requested);
return {
shouldContinue: false,
reply: { text: `✅ TTS provider set to ${requested}.` },
reply: { text: `✅ TTS provider set to ${nextProvider}.` },
};
}
@@ -249,7 +252,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "status") {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider);
const hasKey = isTtsProviderConfigured(config, provider, params.cfg);
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();

View File

@@ -8,7 +8,7 @@ import {
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { buildBrowseProvidersButton } from "../../plugin-sdk-internal/telegram.js";
import { buildBrowseProvidersButton } from "../../plugin-sdk/telegram.js";
import { shortenHomePath } from "../../utils.js";
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
import type { ReplyPayload } from "../types.js";

View File

@@ -0,0 +1,24 @@
import { parseStandardSetUnsetSlashCommand } from "./commands-setunset-standard.js";
export type McpCommand =
| { action: "show"; name?: string }
| { action: "set"; name: string; value: unknown }
| { action: "unset"; name: string }
| { action: "error"; message: string };
export function parseMcpCommand(raw: string): McpCommand | null {
return parseStandardSetUnsetSlashCommand<McpCommand>({
raw,
slash: "/mcp",
invalidMessage: "Invalid /mcp syntax.",
usageMessage: "Usage: /mcp show|set|unset",
onKnownAction: (action, args) => {
if (action === "show" || action === "get") {
return { action: "show", name: args || undefined };
}
return undefined;
},
onSet: (name, value) => ({ action: "set", name, value }),
onUnset: (name) => ({ action: "unset", name }),
});
}

View File

@@ -6,7 +6,7 @@ vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
{ provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" },
{ provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" },
{ provider: "kimi", id: "kimi-code", name: "Kimi Code" },
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
]),
@@ -222,12 +222,12 @@ describe("createModelSelectionState respects session model override", () => {
const state = await resolveState(
makeEntry({
providerOverride: "kimi-coding",
modelOverride: "k2p5",
modelOverride: "kimi-code",
}),
);
expect(state.provider).toBe("kimi-coding");
expect(state.model).toBe("k2p5");
expect(state.provider).toBe("kimi");
expect(state.model).toBe("kimi-code");
});
it("falls back to default when no modelOverride is set", async () => {
@@ -241,8 +241,8 @@ describe("createModelSelectionState respects session model override", () => {
// From issue #14783: stored override should beat last-used fallback model.
const state = await resolveState(
makeEntry({
model: "k2p5",
modelProvider: "kimi-coding",
model: "kimi-code",
modelProvider: "kimi",
contextTokens: 262_000,
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",

View File

@@ -91,6 +91,8 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
enabled: true,
})),
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
webSearchProviders: [],
gatewayHandlers: {},
httpRoutes: [],

View File

@@ -3,7 +3,7 @@ import type {
MediaUnderstandingDecision,
MediaUnderstandingOutput,
} from "../media-understanding/types.js";
import type { StickerMetadata } from "../plugin-sdk-internal/telegram.js";
import type { StickerMetadata } from "../plugin-sdk/telegram.js";
import type { InputProvenance } from "../sessions/input-provenance.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js";

View File

@@ -12,6 +12,8 @@ export type ThinkingCatalogEntry = {
};
const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high", "adaptive"];
const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
export function normalizeProviderId(provider?: string | null): string {
if (!provider) {
@@ -101,6 +103,14 @@ export function resolveThinkingDefaultForModel(params: {
model: string;
catalog?: ThinkingCatalogEntry[];
}): ThinkLevel {
const normalizedProvider = normalizeProviderId(params.provider);
const modelId = params.model.trim();
if (normalizedProvider === "anthropic" && ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId)) {
return "adaptive";
}
if (normalizedProvider === "amazon-bedrock" && AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId)) {
return "adaptive";
}
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);

View File

@@ -7,15 +7,11 @@ export {
monitorWebChannel,
resolveHeartbeatRecipients,
runWebHeartbeatOnce,
} from "./plugin-sdk-internal/whatsapp.js";
export {
extractMediaPlaceholder,
extractText,
monitorWebInbox,
} from "./plugin-sdk-internal/whatsapp.js";
export { loginWeb } from "./plugin-sdk-internal/whatsapp.js";
export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk-internal/whatsapp.js";
export { sendMessageWhatsApp } from "./plugin-sdk-internal/whatsapp.js";
} from "./plugin-sdk/whatsapp.js";
export { extractMediaPlaceholder, extractText, monitorWebInbox } from "./plugin-sdk/whatsapp.js";
export { loginWeb } from "./plugin-sdk/whatsapp.js";
export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk/whatsapp.js";
export { sendMessageWhatsApp } from "./plugin-sdk/whatsapp.js";
export {
createWaSocket,
formatError,
@@ -26,4 +22,4 @@ export {
WA_WEB_AUTH_DIR,
waitForWaConnection,
webAuthExists,
} from "./plugin-sdk-internal/whatsapp.js";
} from "./plugin-sdk/whatsapp.js";

18
src/channels/ids.ts Normal file
View File

@@ -0,0 +1,18 @@
// Keep built-in channel IDs in a leaf module so shared config/sandbox code can
// reference them without importing channel registry helpers that may pull in
// plugin runtime state.
export const CHAT_CHANNEL_ORDER = [
"telegram",
"whatsapp",
"discord",
"irc",
"googlechat",
"slack",
"signal",
"imessage",
"line",
] as const;
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;

View File

@@ -1,2 +1,2 @@
// Public entrypoint for the Discord channel action adapter.
export * from "../../../plugin-sdk-internal/discord.js";
export * from "../../../plugin-sdk/discord.js";

View File

@@ -1,11 +1,11 @@
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
import { resolveSignalAccount } from "../../../plugin-sdk/account-resolution.js";
import {
listEnabledSignalAccounts,
removeReactionSignal,
resolveSignalAccount,
resolveSignalReactionLevel,
sendReactionSignal,
} from "../../../plugin-sdk-internal/signal.js";
} from "../../../plugin-sdk/signal.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
import { resolveReactionMessageId } from "./reaction-message-id.js";

View File

@@ -1,2 +1,2 @@
// Public entrypoint for the Telegram channel action adapter.
export * from "../../../plugin-sdk-internal/telegram.js";
export * from "../../../plugin-sdk/telegram.js";

View File

@@ -1,2 +1,2 @@
// Shim: keep legacy import path while the runtime loads the plugin SDK surface.
export * from "../../../plugin-sdk-internal/whatsapp.js";
export * from "../../../plugin-sdk/whatsapp.js";

View File

@@ -0,0 +1,12 @@
import { describe } from "vitest";
import { directoryContractRegistry } from "./registry.js";
import { installChannelDirectoryContractSuite } from "./suites.js";
for (const entry of directoryContractRegistry) {
describe(`${entry.id} directory contract`, () => {
installChannelDirectoryContractSuite({
plugin: entry.plugin,
invokeLookups: entry.invokeLookups,
});
});
}

View File

@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import {
actionContractRegistry,
directoryContractRegistry,
pluginContractRegistry,
setupContractRegistry,
statusContractRegistry,
surfaceContractRegistry,
threadingContractRegistry,
type ChannelPluginSurface,
} from "./registry.js";
@@ -70,4 +72,26 @@ describe("channel contract registry", () => {
expect(statusSurfaceIds.has(entry.id)).toBe(true);
}
});
it("only installs deep threading coverage for plugins that declare threading", () => {
const threadingSurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("threading"))
.map((entry) => entry.id),
);
for (const entry of threadingContractRegistry) {
expect(threadingSurfaceIds.has(entry.id)).toBe(true);
}
});
it("only installs deep directory coverage for plugins that declare directory", () => {
const directorySurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("directory"))
.map((entry) => entry.id),
);
for (const entry of directoryContractRegistry) {
expect(directorySurfaceIds.has(entry.id)).toBe(true);
}
});
});

View File

@@ -84,6 +84,17 @@ type SurfaceContractEntry = {
surfaces: readonly ChannelPluginSurface[];
};
type ThreadingContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "threading">;
};
type DirectoryContractEntry = {
id: string;
plugin: Pick<ChannelPlugin, "id" | "directory">;
invokeLookups: boolean;
};
const telegramListActionsMock = vi.fn();
const telegramGetCapabilitiesMock = vi.fn();
const discordListActionsMock = vi.fn();
@@ -672,3 +683,20 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [
],
},
];
export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("threading"))
.map((entry) => ({
id: entry.id,
plugin: entry.plugin,
}));
const directoryShapeOnlyIds = new Set(["matrix", "whatsapp", "zalouser"]);
export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("directory"))
.map((entry) => ({
id: entry.id,
plugin: entry.plugin,
invokeLookups: !directoryShapeOnlyIds.has(entry.id),
}));

View File

@@ -0,0 +1,151 @@
import { beforeEach, describe, expect } from "vitest";
import {
__testing as feishuThreadBindingTesting,
createFeishuThreadBindingManager,
} from "../../../../extensions/feishu/src/thread-bindings.js";
import {
__testing as telegramThreadBindingTesting,
createTelegramThreadBindingManager,
} from "../../../../extensions/telegram/src/thread-bindings.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
__testing as sessionBindingTesting,
getSessionBindingService,
} from "../../../infra/outbound/session-binding-service.js";
import { installSessionBindingContractSuite } from "./suites.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
});
describe("feishu session binding contract", () => {
installSessionBindingContractSuite({
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
return getSessionBindingService().getCapabilities({
channel: "feishu",
accountId: "default",
});
},
bindAndResolve: async () => {
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
targetKind: "session",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "codex",
label: "codex-main",
},
});
expect(
service.resolveByConversation({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
}),
)?.toMatchObject({
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
});
return binding;
},
cleanup: async () => {
const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
manager.stop();
expect(
getSessionBindingService().resolveByConversation({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
}),
).toBeNull();
},
});
});
describe("telegram session binding contract", () => {
installSessionBindingContractSuite({
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "telegram",
accountId: "default",
});
},
bindAndResolve: async () => {
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
placement: "current",
metadata: {
boundBy: "user-1",
},
});
expect(
service.resolveByConversation({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
}),
)?.toMatchObject({
targetSessionKey: "agent:main:subagent:child-1",
});
return binding;
},
cleanup: async () => {
const manager = createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
manager.stop();
expect(
getSessionBindingService().resolveByConversation({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
}),
).toBeNull();
},
});
});

View File

@@ -5,13 +5,22 @@ import type {
ResolveProviderRuntimeGroupPolicyParams,
RuntimeGroupPolicyResolution,
} from "../../../config/runtime-group-policy.js";
import type {
SessionBindingCapabilities,
SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import { createNonExitingRuntime } from "../../../runtime.js";
import { normalizeChatType } from "../../chat-type.js";
import { resolveConversationLabel } from "../../conversation-label.js";
import { validateSenderIdentity } from "../../sender-identity.js";
import type {
ChannelAccountSnapshot,
ChannelAccountState,
ChannelDirectoryEntry,
ChannelFocusedBindingContext,
ChannelReplyTransport,
ChannelSetupInput,
ChannelThreadingToolContext,
} from "../types.core.js";
import type {
ChannelMessageActionName,
@@ -23,6 +32,69 @@ function sortStrings(values: readonly string[]) {
return [...values].toSorted((left, right) => left.localeCompare(right));
}
const contractRuntime = createNonExitingRuntime();
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
expect(["user", "group", "channel"]).toContain(entry.kind);
expect(typeof entry.id).toBe("string");
expect(entry.id.trim()).not.toBe("");
if (entry.name !== undefined) {
expect(typeof entry.name).toBe("string");
}
if (entry.handle !== undefined) {
expect(typeof entry.handle).toBe("string");
}
if (entry.avatarUrl !== undefined) {
expect(typeof entry.avatarUrl).toBe("string");
}
if (entry.rank !== undefined) {
expect(typeof entry.rank).toBe("number");
}
}
function expectThreadingToolContextShape(context: ChannelThreadingToolContext) {
if (context.currentChannelId !== undefined) {
expect(typeof context.currentChannelId).toBe("string");
}
if (context.currentChannelProvider !== undefined) {
expect(typeof context.currentChannelProvider).toBe("string");
}
if (context.currentThreadTs !== undefined) {
expect(typeof context.currentThreadTs).toBe("string");
}
if (context.currentMessageId !== undefined) {
expect(["string", "number"]).toContain(typeof context.currentMessageId);
}
if (context.replyToMode !== undefined) {
expect(["off", "first", "all"]).toContain(context.replyToMode);
}
if (context.hasRepliedRef !== undefined) {
expect(typeof context.hasRepliedRef).toBe("object");
}
if (context.skipCrossContextDecoration !== undefined) {
expect(typeof context.skipCrossContextDecoration).toBe("boolean");
}
}
function expectReplyTransportShape(transport: ChannelReplyTransport) {
if (transport.replyToId !== undefined && transport.replyToId !== null) {
expect(typeof transport.replyToId).toBe("string");
}
if (transport.threadId !== undefined && transport.threadId !== null) {
expect(["string", "number"]).toContain(typeof transport.threadId);
}
}
function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) {
expect(typeof binding.conversationId).toBe("string");
expect(binding.conversationId.trim()).not.toBe("");
if (binding.parentConversationId !== undefined) {
expect(typeof binding.parentConversationId).toBe("string");
}
expect(["current", "child"]).toContain(binding.placement);
expect(typeof binding.labelNoun).toBe("string");
expect(binding.labelNoun.trim()).not.toBe("");
}
export function installChannelPluginContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
}) {
@@ -228,6 +300,188 @@ export function installChannelSurfaceContractSuite(params: {
});
}
export function installChannelThreadingContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "threading">;
}) {
it("exposes the base threading contract", () => {
expect(params.plugin.threading).toBeDefined();
});
it("keeps threading return values normalized", () => {
const threading = params.plugin.threading;
expect(threading).toBeDefined();
if (threading?.resolveReplyToMode) {
expect(
["off", "first", "all"].includes(
threading.resolveReplyToMode({
cfg: {} as OpenClawConfig,
accountId: "default",
chatType: "group",
}),
),
).toBe(true);
}
const repliedRef = { value: false };
const toolContext = threading?.buildToolContext?.({
cfg: {} as OpenClawConfig,
accountId: "default",
context: {
Channel: "group:test",
From: "user:test",
To: "group:test",
ChatType: "group",
CurrentMessageId: "msg-1",
ReplyToId: "msg-0",
ReplyToIdFull: "thread-0",
MessageThreadId: "thread-0",
NativeChannelId: "native:test",
},
hasRepliedRef: repliedRef,
});
if (toolContext) {
expectThreadingToolContextShape(toolContext);
if (toolContext.hasRepliedRef) {
expect(toolContext.hasRepliedRef).toBe(repliedRef);
}
}
const autoThreadId = threading?.resolveAutoThreadId?.({
cfg: {} as OpenClawConfig,
accountId: "default",
to: "group:test",
toolContext,
replyToId: null,
});
if (autoThreadId !== undefined) {
expect(typeof autoThreadId).toBe("string");
expect(autoThreadId.trim()).not.toBe("");
}
const replyTransport = threading?.resolveReplyTransport?.({
cfg: {} as OpenClawConfig,
accountId: "default",
threadId: "thread-0",
replyToId: "msg-0",
});
if (replyTransport) {
expectReplyTransportShape(replyTransport);
}
const focusedBinding = threading?.resolveFocusedBinding?.({
cfg: {} as OpenClawConfig,
accountId: "default",
context: {
Channel: "group:test",
From: "user:test",
To: "group:test",
ChatType: "group",
CurrentMessageId: "msg-1",
ReplyToId: "msg-0",
ReplyToIdFull: "thread-0",
MessageThreadId: "thread-0",
NativeChannelId: "native:test",
},
});
if (focusedBinding) {
expectFocusedBindingShape(focusedBinding);
}
});
}
export function installChannelDirectoryContractSuite(params: {
plugin: Pick<ChannelPlugin, "id" | "directory">;
invokeLookups?: boolean;
}) {
it("exposes the base directory contract", async () => {
const directory = params.plugin.directory;
expect(directory).toBeDefined();
if (params.invokeLookups === false) {
return;
}
const self = await directory?.self?.({
cfg: {} as OpenClawConfig,
accountId: "default",
runtime: contractRuntime,
});
if (self) {
expectDirectoryEntryShape(self);
}
const peers =
(await directory?.listPeers?.({
cfg: {} as OpenClawConfig,
accountId: "default",
query: "",
limit: 5,
runtime: contractRuntime,
})) ?? [];
expect(Array.isArray(peers)).toBe(true);
for (const peer of peers) {
expectDirectoryEntryShape(peer);
}
const groups =
(await directory?.listGroups?.({
cfg: {} as OpenClawConfig,
accountId: "default",
query: "",
limit: 5,
runtime: contractRuntime,
})) ?? [];
expect(Array.isArray(groups)).toBe(true);
for (const group of groups) {
expectDirectoryEntryShape(group);
}
if (directory?.listGroupMembers && groups[0]?.id) {
const members = await directory.listGroupMembers({
cfg: {} as OpenClawConfig,
accountId: "default",
groupId: groups[0].id,
limit: 5,
runtime: contractRuntime,
});
expect(Array.isArray(members)).toBe(true);
for (const member of members) {
expectDirectoryEntryShape(member);
}
}
});
}
export function installSessionBindingContractSuite(params: {
getCapabilities: () => SessionBindingCapabilities;
bindAndResolve: () => Promise<SessionBindingRecord>;
cleanup: () => Promise<void> | void;
expectedCapabilities: SessionBindingCapabilities;
}) {
it("registers the expected session binding capabilities", () => {
expect(params.getCapabilities()).toEqual(params.expectedCapabilities);
});
it("binds and resolves a session binding through the shared service", async () => {
const binding = await params.bindAndResolve();
expect(typeof binding.bindingId).toBe("string");
expect(binding.bindingId.trim()).not.toBe("");
expect(typeof binding.targetSessionKey).toBe("string");
expect(binding.targetSessionKey.trim()).not.toBe("");
expect(["session", "subagent"]).toContain(binding.targetKind);
expect(typeof binding.conversation.channel).toBe("string");
expect(typeof binding.conversation.accountId).toBe("string");
expect(typeof binding.conversation.conversationId).toBe("string");
expect(["active", "ending", "ended"]).toContain(binding.status);
expect(typeof binding.boundAt).toBe("number");
});
it("cleans up registered bindings", async () => {
await params.cleanup();
});
}
type ChannelSetupContractCase<ResolvedAccount> = {
name: string;
cfg: OpenClawConfig;

View File

@@ -0,0 +1,11 @@
import { describe } from "vitest";
import { threadingContractRegistry } from "./registry.js";
import { installChannelThreadingContractSuite } from "./suites.js";
for (const entry of threadingContractRegistry) {
describe(`${entry.id} threading contract`, () => {
installChannelThreadingContractSuite({
plugin: entry.plugin,
});
});
}

View File

@@ -10,7 +10,7 @@ import type {
GroupToolPolicyConfig,
} from "../../config/types.tools.js";
import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js";
import { inspectSlackAccount } from "../../plugin-sdk-internal/slack.js";
import { inspectSlackAccount } from "../../plugin-sdk/slack.js";
import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js";
import type { ChannelGroupContext } from "./types.js";

View File

@@ -1,13 +1,16 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { defaultRuntime } from "../../runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import {
__testing,
channelSupportsMessageCapability,
channelSupportsMessageCapabilityForChannel,
listChannelMessageActions,
listChannelMessageCapabilities,
listChannelMessageCapabilitiesForChannel,
} from "./message-actions.js";
@@ -56,8 +59,12 @@ function activateMessageActionTestRegistry() {
}
describe("message action capability checks", () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
__testing.resetLoggedMessageActionErrors();
errorSpy.mockClear();
});
it("aggregates capabilities across plugins", () => {
@@ -122,4 +129,36 @@ describe("message action capability checks", () => {
false,
);
});
it("skips crashing action/capability discovery paths and logs once", () => {
const crashingPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "discord",
label: "Discord",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
},
}),
actions: {
listActions: () => {
throw new Error("boom");
},
getCapabilities: () => {
throw new Error("boom");
},
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: crashingPlugin }]),
);
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(2);
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,5 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { getChannelPlugin, listChannelPlugins } from "./index.js";
import type { ChannelMessageCapability } from "./message-capabilities.js";
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
@@ -16,13 +17,54 @@ function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boole
);
}
const loggedMessageActionErrors = new Set<string>();
function logMessageActionError(params: {
pluginId: string;
operation: "listActions" | "getCapabilities";
error: unknown;
}) {
const message = params.error instanceof Error ? params.error.message : String(params.error);
const key = `${params.pluginId}:${params.operation}:${message}`;
if (loggedMessageActionErrors.has(key)) {
return;
}
loggedMessageActionErrors.add(key);
const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null;
defaultRuntime.error?.(
`[message-actions] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`,
);
}
function runListActionsSafely(params: {
pluginId: string;
cfg: OpenClawConfig;
listActions: NonNullable<ChannelActions["listActions"]>;
}): ChannelMessageActionName[] {
try {
const listed = params.listActions({ cfg: params.cfg });
return Array.isArray(listed) ? listed : [];
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "listActions",
error,
});
return [];
}
}
export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] {
const actions = new Set<ChannelMessageActionName>(["send", "broadcast"]);
for (const plugin of listChannelPlugins()) {
const list = plugin.actions?.listActions?.({ cfg });
if (!list) {
if (!plugin.actions?.listActions) {
continue;
}
const list = runListActionsSafely({
pluginId: plugin.id,
cfg,
listActions: plugin.actions.listActions,
});
for (const action of list) {
actions.add(action);
}
@@ -30,11 +72,21 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
return Array.from(actions);
}
function listCapabilities(
actions: ChannelActions,
cfg: OpenClawConfig,
): readonly ChannelMessageCapability[] {
return actions.getCapabilities?.({ cfg }) ?? [];
function listCapabilities(params: {
pluginId: string;
actions: ChannelActions;
cfg: OpenClawConfig;
}): readonly ChannelMessageCapability[] {
try {
return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? [];
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "getCapabilities",
error,
});
return [];
}
}
export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] {
@@ -43,7 +95,11 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess
if (!plugin.actions) {
continue;
}
for (const capability of listCapabilities(plugin.actions, cfg)) {
for (const capability of listCapabilities({
pluginId: plugin.id,
actions: plugin.actions,
cfg,
})) {
capabilities.add(capability);
}
}
@@ -58,7 +114,15 @@ export function listChannelMessageCapabilitiesForChannel(params: {
return [];
}
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
return plugin?.actions ? Array.from(listCapabilities(plugin.actions, params.cfg)) : [];
return plugin?.actions
? Array.from(
listCapabilities({
pluginId: plugin.id,
actions: plugin.actions,
cfg: params.cfg,
}),
)
: [];
}
export function channelSupportsMessageCapability(
@@ -95,3 +159,9 @@ export async function dispatchChannelMessageAction(
}
return await plugin.actions.handleAction(ctx);
}
export const __testing = {
resetLoggedMessageActionErrors() {
loggedMessageActionErrors.clear();
},
};

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { applySetupAccountConfigPatch } from "./setup-helpers.js";
import {
applySetupAccountConfigPatch,
createPatchedAccountSetupAdapter,
prepareScopedSetupConfig,
} from "./setup-helpers.js";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
@@ -79,3 +83,121 @@ describe("applySetupAccountConfigPatch", () => {
});
});
});
describe("createPatchedAccountSetupAdapter", () => {
it("stores default-account patch at channel root", () => {
const adapter = createPatchedAccountSetupAdapter({
channelKey: "zalo",
buildPatch: (input) => ({ botToken: input.token }),
});
const next = adapter.applyAccountConfig({
cfg: asConfig({ channels: { zalo: { enabled: false } } }),
accountId: DEFAULT_ACCOUNT_ID,
input: { name: "Personal", token: "tok" },
});
expect(next.channels?.zalo).toMatchObject({
enabled: true,
name: "Personal",
botToken: "tok",
});
});
it("migrates base name into the default account before patching a named account", () => {
const adapter = createPatchedAccountSetupAdapter({
channelKey: "zalo",
buildPatch: (input) => ({ botToken: input.token }),
});
const next = adapter.applyAccountConfig({
cfg: asConfig({
channels: {
zalo: {
name: "Personal",
accounts: {
work: { botToken: "old" },
},
},
},
}),
accountId: "Work Team",
input: { name: "Work", token: "new" },
});
expect(next.channels?.zalo).toMatchObject({
accounts: {
default: { name: "Personal" },
work: { botToken: "old" },
"work-team": { enabled: true, name: "Work", botToken: "new" },
},
});
expect(next.channels?.zalo).not.toHaveProperty("name");
});
it("can store the default account in accounts.default", () => {
const adapter = createPatchedAccountSetupAdapter({
channelKey: "whatsapp",
alwaysUseAccounts: true,
buildPatch: (input) => ({ authDir: input.authDir }),
});
const next = adapter.applyAccountConfig({
cfg: asConfig({ channels: { whatsapp: {} } }),
accountId: DEFAULT_ACCOUNT_ID,
input: { name: "Phone", authDir: "/tmp/auth" },
});
expect(next.channels?.whatsapp).toMatchObject({
accounts: {
default: {
enabled: true,
name: "Phone",
authDir: "/tmp/auth",
},
},
});
expect(next.channels?.whatsapp).not.toHaveProperty("enabled");
expect(next.channels?.whatsapp).not.toHaveProperty("authDir");
});
});
describe("prepareScopedSetupConfig", () => {
it("stores the name and migrates it for named accounts when requested", () => {
const next = prepareScopedSetupConfig({
cfg: asConfig({
channels: {
bluebubbles: {
name: "Personal",
},
},
}),
channelKey: "bluebubbles",
accountId: "Work Team",
name: "Work",
migrateBaseName: true,
});
expect(next.channels?.bluebubbles).toMatchObject({
accounts: {
default: { name: "Personal" },
"work-team": { name: "Work" },
},
});
expect(next.channels?.bluebubbles).not.toHaveProperty("name");
});
it("keeps the base shape for the default account when migration is disabled", () => {
const next = prepareScopedSetupConfig({
cfg: asConfig({ channels: { irc: { enabled: true } } }),
channelKey: "irc",
accountId: DEFAULT_ACCOUNT_ID,
name: "Libera",
});
expect(next.channels?.irc).toMatchObject({
enabled: true,
name: "Libera",
});
});
});

View File

@@ -1,5 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type { ChannelSetupAdapter } from "./types.adapters.js";
import type { ChannelSetupInput } from "./types.core.js";
type ChannelSectionBase = {
name?: string;
@@ -120,6 +122,31 @@ export function migrateBaseNameToDefaultAccount(params: {
} as OpenClawConfig;
}
export function prepareScopedSetupConfig(params: {
cfg: OpenClawConfig;
channelKey: string;
accountId: string;
name?: string;
alwaysUseAccounts?: boolean;
migrateBaseName?: boolean;
}): OpenClawConfig {
const namedConfig = applyAccountNameToChannelSection({
cfg: params.cfg,
channelKey: params.channelKey,
accountId: params.accountId,
name: params.name,
alwaysUseAccounts: params.alwaysUseAccounts,
});
if (!params.migrateBaseName || normalizeAccountId(params.accountId) === DEFAULT_ACCOUNT_ID) {
return namedConfig;
}
return migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: params.channelKey,
alwaysUseAccounts: params.alwaysUseAccounts,
});
}
export function applySetupAccountConfigPatch(params: {
cfg: OpenClawConfig;
channelKey: string;
@@ -134,6 +161,49 @@ export function applySetupAccountConfigPatch(params: {
});
}
export function createPatchedAccountSetupAdapter(params: {
channelKey: string;
alwaysUseAccounts?: boolean;
ensureChannelEnabled?: boolean;
ensureAccountEnabled?: boolean;
validateInput?: ChannelSetupAdapter["validateInput"];
buildPatch: (input: ChannelSetupInput) => Record<string, unknown>;
}): ChannelSetupAdapter {
return {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
prepareScopedSetupConfig({
cfg,
channelKey: params.channelKey,
accountId,
name,
alwaysUseAccounts: params.alwaysUseAccounts,
}),
validateInput: params.validateInput,
applyAccountConfig: ({ cfg, accountId, input }) => {
const next = prepareScopedSetupConfig({
cfg,
channelKey: params.channelKey,
accountId,
name: input.name,
alwaysUseAccounts: params.alwaysUseAccounts,
migrateBaseName: !params.alwaysUseAccounts,
});
const patch = params.buildPatch(input);
return patchScopedAccountConfig({
cfg: next,
channelKey: params.channelKey,
accountId,
patch,
accountPatch: patch,
ensureChannelEnabled: params.ensureChannelEnabled ?? !params.alwaysUseAccounts,
ensureAccountEnabled: params.ensureAccountEnabled ?? true,
scopeDefaultToAccounts: params.alwaysUseAccounts,
});
},
};
}
export function patchScopedAccountConfig(params: {
cfg: OpenClawConfig;
channelKey: string;
@@ -142,6 +212,7 @@ export function patchScopedAccountConfig(params: {
accountPatch?: Record<string, unknown>;
ensureChannelEnabled?: boolean;
ensureAccountEnabled?: boolean;
scopeDefaultToAccounts?: boolean;
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const channels = params.cfg.channels as Record<string, unknown> | undefined;
@@ -156,7 +227,7 @@ export function patchScopedAccountConfig(params: {
const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled;
const patch = params.patch;
const accountPatch = params.accountPatch ?? patch;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) {
return {
...params.cfg,
channels: {

View File

@@ -1,10 +1,10 @@
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../../commands/auth-choice.apply-helpers.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import type { SecretInput } from "../../config/types.secrets.js";
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../../plugins/provider-auth-input.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import {

View File

@@ -0,0 +1,61 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js";
import type { ChannelSetupWizard } from "./setup-wizard.js";
type PromptAllowFromParams = Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0];
type ResolveAllowFromEntriesParams = Parameters<
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
>[0];
type ResolveAllowFromEntriesResult = Awaited<
ReturnType<NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]>
>;
type ResolveGroupAllowlistParams = Parameters<
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>[0];
export function createAllowlistSetupWizardProxy<TGroupResolved>(params: {
loadWizard: () => Promise<ChannelSetupWizard>;
createBase: (handlers: {
promptAllowFrom: (params: PromptAllowFromParams) => Promise<OpenClawConfig>;
resolveAllowFromEntries: (
params: ResolveAllowFromEntriesParams,
) => Promise<ResolveAllowFromEntriesResult>;
resolveGroupAllowlist: (params: ResolveGroupAllowlistParams) => Promise<TGroupResolved>;
}) => ChannelSetupWizard;
fallbackResolvedGroupAllowlist: (entries: string[]) => TGroupResolved;
}) {
return params.createBase({
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const wizard = await params.loadWizard();
if (!wizard.dmPolicy?.promptAllowFrom) {
return cfg;
}
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
},
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => {
const wizard = await params.loadWizard();
if (!wizard.allowFrom) {
return entries.map((input) => ({ input, resolved: false, id: null }));
}
return await wizard.allowFrom.resolveEntries({
cfg,
accountId,
credentialValues,
entries,
});
},
resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const wizard = await params.loadWizard();
if (!wizard.groupAccess?.resolveAllowlist) {
return params.fallbackResolvedGroupAllowlist(entries);
}
return (await wizard.groupAccess.resolveAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
})) as TGroupResolved;
},
});
}

View File

@@ -4,8 +4,8 @@ import {
isSlackInteractiveRepliesEnabled,
listSlackMessageActions,
resolveSlackChannelId,
} from "../../plugin-sdk-internal/slack.js";
import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js";
handleSlackMessageAction,
} from "../../plugin-sdk/slack.js";
import type { ChannelMessageActionAdapter } from "./types.js";
export function createSlackActions(providerId: string): ChannelMessageActionAdapter {

View File

@@ -1,2 +1,2 @@
export { inspectDiscordAccount } from "../plugin-sdk-internal/discord.js";
export type { InspectedDiscordAccount } from "../plugin-sdk-internal/discord.js";
export { inspectDiscordAccount } from "../plugin-sdk/discord.js";
export type { InspectedDiscordAccount } from "../plugin-sdk/discord.js";

View File

@@ -1,2 +1,2 @@
export { inspectSlackAccount } from "../plugin-sdk-internal/slack.js";
export type { InspectedSlackAccount } from "../plugin-sdk-internal/slack.js";
export { inspectSlackAccount } from "../plugin-sdk/slack.js";
export type { InspectedSlackAccount } from "../plugin-sdk/slack.js";

Some files were not shown because too many files have changed in this diff Show More