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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 0 additions & 62 deletions packages/core/agents/openclaw-runtime-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { describe, expect, it } from "vitest";
import {
OPENCLAW_GATEWAY_TOKEN_MASK,
openclawRuntimeConfigEquals,
parseOpenclawRuntimeConfig,
serializeOpenclawRuntimeConfig,
} from "./openclaw-runtime-config";

Expand Down Expand Up @@ -63,63 +61,3 @@ describe("serializeOpenclawRuntimeConfig", () => {
});
});
});

// `runtime_config` is one shared JSONB column and the OpenClaw tab persists its
// serialized output as the WHOLE object. Dropping unknown keys therefore let an
// unrelated routing save silently delete `mcp.inherit_runtime`, which controls
// whether the agent may reach the host's MCP servers (GitHub #6283).
describe("openclaw runtime_config passthrough", () => {
it("preserves keys owned by other tabs across a parse/serialize round-trip", () => {
const stored = {
mode: "gateway",
gateway: { host: "box.local", port: 4599 },
mcp: { inherit_runtime: true },
};

const parsed = parseOpenclawRuntimeConfig(stored);
expect(parsed.passthrough).toEqual({ mcp: { inherit_runtime: true } });

expect(serializeOpenclawRuntimeConfig(parsed)).toEqual(stored);
});

it("keeps the foreign keys when the OpenClaw settings themselves change", () => {
const parsed = parseOpenclawRuntimeConfig({
mode: "gateway",
gateway: { host: "old.local" },
mcp: { inherit_runtime: true },
});

// Simulate the form switching back to local mode and saving.
const saved = serializeOpenclawRuntimeConfig({
mode: "local",
passthrough: parsed.passthrough,
});

expect(saved).toEqual({ mode: "local", mcp: { inherit_runtime: true } });
});

it("never lets a foreign key overwrite the fields this form owns", () => {
const saved = serializeOpenclawRuntimeConfig({
mode: "local",
passthrough: { mode: "gateway", mcp: { inherit_runtime: false } },
});

expect(saved.mode).toBe("local");
expect(saved.mcp).toEqual({ inherit_runtime: false });
});

it("omits passthrough entirely when there are no foreign keys", () => {
const parsed = parseOpenclawRuntimeConfig({ mode: "local" });
expect(parsed.passthrough).toBeUndefined();
expect(serializeOpenclawRuntimeConfig(parsed)).toEqual({ mode: "local" });
});

it("does not report a config as dirty because of passthrough alone", () => {
expect(
openclawRuntimeConfigEquals(
{ mode: "local", passthrough: { mcp: { inherit_runtime: true } } },
{ mode: "local" },
),
).toBe(true);
});
});
36 changes: 3 additions & 33 deletions packages/core/agents/openclaw-runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,6 @@ export interface OpenclawGatewayPin {
export interface OpenclawRuntimeConfig {
mode?: OpenclawRoutingMode;
gateway?: OpenclawGatewayPin;
/**
* Keys of `runtime_config` this form does not own, carried through parse →
* serialize untouched so saving OpenClaw routing cannot delete settings
* stored alongside it — notably `mcp.inherit_runtime`, which decides whether
* the agent may reach the runtime host's MCP servers (GitHub #6283).
*
* Deliberately excluded from `openclawRuntimeConfigEquals`: the form never
* edits these, so they must not make it look dirty.
*/
passthrough?: Record<string, unknown>;
}

// Sentinel the API substitutes for a non-empty `gateway.token` on every read.
Expand All @@ -36,10 +26,9 @@ export interface OpenclawRuntimeConfig {
export const OPENCLAW_GATEWAY_TOKEN_MASK = "***";

// Parse an arbitrary runtime_config payload into the typed schema. Unknown
// keys are preserved verbatim under `passthrough` (see below), malformed
// payloads collapse to an empty object. The form never throws on bad input —
// invalid configs simply render as defaults so the user can correct them
// without a JSON parse error blocking the UI.
// keys are dropped, malformed payloads collapse to an empty object. The form
// never throws on bad input — invalid configs simply render as defaults so
// the user can correct them without a JSON parse error blocking the UI.
export function parseOpenclawRuntimeConfig(
raw: unknown,
): OpenclawRuntimeConfig {
Expand All @@ -58,18 +47,6 @@ export function parseOpenclawRuntimeConfig(
if (typeof gw.tls === "boolean") pin.tls = gw.tls;
if (Object.keys(pin).length > 0) out.gateway = pin;
}
// Keep every key this form does not own. `runtime_config` is a single JSONB
// column shared with settings owned elsewhere — notably
// `mcp.inherit_runtime`, which controls whether the agent may reach the
// host's MCP servers (GitHub #6283). Dropping unknown keys here made saving
// an unrelated OpenClaw routing change silently reset that security setting,
// because the tab persists this parse result as the WHOLE object.
const passthrough: Record<string, unknown> = {};
for (const [key, value] of Object.entries(root)) {
if (key === "mode" || key === "gateway") continue;
passthrough[key] = value;
}
if (Object.keys(passthrough).length > 0) out.passthrough = passthrough;
return out;
}

Expand All @@ -81,13 +58,6 @@ export function serializeOpenclawRuntimeConfig(
cfg: OpenclawRuntimeConfig,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
// Settings owned by other tabs go back first so this form can never clobber
// them; `mode`/`gateway` below are the only keys it owns.
if (cfg.passthrough) {
for (const [key, value] of Object.entries(cfg.passthrough)) {
out[key] = value;
}
}
if (cfg.mode) out.mode = cfg.mode;
if (cfg.gateway) {
const gw: Record<string, unknown> = {};
Expand Down
92 changes: 0 additions & 92 deletions packages/core/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1706,95 +1706,3 @@ describe("ApiClient unsubscribe endpoints", () => {
).rejects.toBeInstanceOf(ApiError);
});
});

// Runtime capability discovery feeds the agent MCP tab's security copy, so a
// malformed body must degrade to an explicit failure that asserts NO MCP
// boundary — never a fabricated guarantee (GitHub #6283).
describe("ApiClient runtime capability discovery", () => {
function stubJSON(body: unknown) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
}

it("degrades a malformed initiate response to an explicit failure", async () => {
stubJSON({ status: 7, skills: "nope" });

const result = await new ApiClient(
"https://api.example.test",
).initiateListLocalSkills("rt-1");

expect(result.status).toBe("failed");
expect(result.authoritative_mcp).toBe(false);
expect(result.mcp_supported).toBe(false);
expect(result.error).toBe("invalid runtime capability response");
expect(result.runtime_id).toBe("rt-1");
});

it("degrades a malformed poll response to an explicit failure", async () => {
stubJSON("not-an-object");

const result = await new ApiClient(
"https://api.example.test",
).getListLocalSkillsResult("rt-1", "req-1");

expect(result.status).toBe("failed");
expect(result.authoritative_mcp).toBe(false);
expect(result.id).toBe("req-1");
expect(result.runtime_id).toBe("rt-1");
});

// An older daemon omits the flag entirely; the response is otherwise valid and
// must stay usable, with the flag reading false.
it("keeps an older daemon's response usable with authoritative_mcp false", async () => {
stubJSON({
id: "req-1",
runtime_id: "rt-1",
status: "completed",
supported: true,
skills: [],
mcp_servers: [
{ name: "linear", transport: "http", enabled: true },
],
mcp_supported: true,
created_at: "",
updated_at: "",
});

const result = await new ApiClient(
"https://api.example.test",
).getListLocalSkillsResult("rt-1", "req-1");

expect(result.status).toBe("completed");
expect(result.mcp_supported).toBe(true);
expect(result.authoritative_mcp).toBe(false);
expect(result.mcp_servers?.[0]?.name).toBe("linear");
});

it("passes a current daemon's authoritative_mcp through", async () => {
stubJSON({
id: "req-1",
runtime_id: "rt-1",
status: "completed",
supported: true,
skills: [],
mcp_servers: [],
mcp_supported: true,
authoritative_mcp: true,
created_at: "",
updated_at: "",
});

const result = await new ApiClient(
"https://api.example.test",
).getListLocalSkillsResult("rt-1", "req-1");

expect(result.authoritative_mcp).toBe(true);
});
});
36 changes: 4 additions & 32 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,6 @@ import {
EMPTY_LIST_GITHUB_REPOSITORIES_RESPONSE,
RuntimeModelListRequestSchema,
MALFORMED_RUNTIME_MODEL_LIST_REQUEST,
RuntimeLocalSkillListRequestSchema,
MALFORMED_RUNTIME_LOCAL_SKILL_LIST_REQUEST,
} from "./schemas";

/** Identifies the calling client to the server.
Expand Down Expand Up @@ -1783,45 +1781,19 @@ export class ApiClient {
);
}

// Both capability-discovery endpoints feed the same poll-then-render state
// machine as model discovery, and their `authoritative_mcp` flag decides
// whether the agent MCP tab may claim the runtime's own MCP servers are
// excluded from an agent. That is a security statement, so the body is
// validated rather than cast: an unparseable response degrades to an explicit
// "failed" record with authoritative_mcp false, never to a fabricated
// guarantee (GitHub #6283).
async initiateListLocalSkills(
runtimeId: string,
): Promise<RuntimeLocalSkillListRequest> {
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/local-skills`,
{ method: "POST" },
);
return parseWithFallback<RuntimeLocalSkillListRequest>(
raw,
RuntimeLocalSkillListRequestSchema,
{ ...MALFORMED_RUNTIME_LOCAL_SKILL_LIST_REQUEST, runtime_id: runtimeId },
{ endpoint: "POST /api/runtimes/{id}/local-skills" },
);
return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, {
method: "POST",
});
}

async getListLocalSkillsResult(
runtimeId: string,
requestId: string,
): Promise<RuntimeLocalSkillListRequest> {
const raw = await this.fetch<unknown>(
`/api/runtimes/${runtimeId}/local-skills/${requestId}`,
);
return parseWithFallback<RuntimeLocalSkillListRequest>(
raw,
RuntimeLocalSkillListRequestSchema,
{
...MALFORMED_RUNTIME_LOCAL_SKILL_LIST_REQUEST,
id: requestId,
runtime_id: runtimeId,
},
{ endpoint: "GET /api/runtimes/{id}/local-skills/{requestId}" },
);
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`);
}

async initiateImportLocalSkill(
Expand Down
Loading
Loading