From c5893944503965a32b5472c2e13e8dc42e8d720c Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 27 Apr 2026 11:15:29 -0500 Subject: [PATCH] fix(coven): bound runtime metadata --- extensions/coven/src/client.ts | 14 +---------- extensions/coven/src/config.ts | 23 +----------------- extensions/coven/src/path-utils.ts | 23 ++++++++++++++++++ extensions/coven/src/runtime.test.ts | 16 +++++++++++++ extensions/coven/src/runtime.ts | 36 +++++++++++++++------------- 5 files changed, 61 insertions(+), 51 deletions(-) create mode 100644 extensions/coven/src/path-utils.ts diff --git a/extensions/coven/src/client.ts b/extensions/coven/src/client.ts index f77a462c236..07e59cff7f0 100644 --- a/extensions/coven/src/client.ts +++ b/extensions/coven/src/client.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import http from "node:http"; import net from "node:net"; import path from "node:path"; +import { lstatIfExists, pathIsInside } from "./path-utils.js"; export type CovenSessionRecord = { id: string; @@ -86,19 +87,6 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; const MAX_REQUEST_BYTES = 1_000_000; const MAX_RESPONSE_BYTES = 1_000_000; -function pathIsInside(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function lstatIfExists(filePath: string): fs.Stats | null { - try { - return fs.lstatSync(filePath); - } catch { - return null; - } -} - function statExistingPath(filePath: string, label: string): fs.Stats { try { return fs.statSync(filePath); diff --git a/extensions/coven/src/config.ts b/extensions/coven/src/config.ts index 49e590a7c58..16a9282fa09 100644 --- a/extensions/coven/src/config.ts +++ b/extensions/coven/src/config.ts @@ -1,8 +1,8 @@ -import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { z } from "openclaw/plugin-sdk/zod"; +import { lstatIfExists, pathIsInside, realpathIfExists } from "./path-utils.js"; export type CovenPluginConfig = { covenHome?: string; @@ -62,27 +62,6 @@ function resolveConfiguredPath(raw: string, label: "covenHome" | "socketPath"): return path.resolve(expanded); } -function pathIsInside(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function realpathIfExists(filePath: string): string | null { - try { - return fs.realpathSync.native(filePath); - } catch { - return null; - } -} - -function lstatIfExists(filePath: string): fs.Stats | null { - try { - return fs.lstatSync(filePath); - } catch { - return null; - } -} - function resolveCovenHome(raw: string | undefined): string { const fromConfig = raw?.trim(); if (fromConfig) { diff --git a/extensions/coven/src/path-utils.ts b/extensions/coven/src/path-utils.ts new file mode 100644 index 00000000000..41d377e746e --- /dev/null +++ b/extensions/coven/src/path-utils.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function pathIsInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function realpathIfExists(filePath: string): string | null { + try { + return fs.realpathSync.native(filePath); + } catch { + return null; + } +} + +export function lstatIfExists(filePath: string): fs.Stats | null { + try { + return fs.lstatSync(filePath); + } catch { + return null; + } +} diff --git a/extensions/coven/src/runtime.test.ts b/extensions/coven/src/runtime.test.ts index b982d1ccde2..ea7d632a84e 100644 --- a/extensions/coven/src/runtime.test.ts +++ b/extensions/coven/src/runtime.test.ts @@ -531,6 +531,22 @@ describe("CovenAcpRuntime", () => { expect(__testing.decodeRuntimeSessionName(`coven:${"a".repeat(2_049)}`)).toBeNull(); }); + it("bounds encoded Coven runtime session metadata before persistence", () => { + const encoded = __testing.encodeRuntimeSessionName({ + agent: "A".repeat(5_000), + mode: "prompt".repeat(1_000), + sessionMode: "persistent".repeat(1_000), + cwd: "/workspace/".repeat(1_000), + }); + + expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual("coven:".length + 2_048); + expect(__testing.decodeRuntimeSessionName(encoded)).toEqual({ + agent: "a".repeat(128), + mode: "promptpromptpromptpromptpromptpr", + sessionMode: "persistentpersistentpersistentpe", + }); + }); + it("rejects missing Coven cwd paths before launching", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-coven-workspace-")); try { diff --git a/extensions/coven/src/runtime.ts b/extensions/coven/src/runtime.ts index d13cb8f456a..a547625a808 100644 --- a/extensions/coven/src/runtime.ts +++ b/extensions/coven/src/runtime.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import path from "node:path"; import { AcpRuntimeError, @@ -18,6 +17,7 @@ import { type CovenSessionRecord, } from "./client.js"; import type { ResolvedCovenPluginConfig } from "./config.js"; +import { pathIsInside, realpathIfExists } from "./path-utils.js"; export const COVEN_BACKEND_ID = "coven"; @@ -40,6 +40,8 @@ const MAX_EVENTS_PER_POLL = 500; const MAX_EVENT_PAYLOAD_BYTES = 64_000; const MAX_TRACKED_EVENT_IDS = 10_000; const MAX_RUNTIME_SESSION_NAME_BYTES = 2_048; +const MAX_RUNTIME_AGENT_CHARS = 128; +const MAX_RUNTIME_MODE_CHARS = 32; const MAX_STATUS_FIELD_CHARS = 256; type CovenRuntimeSessionState = { @@ -61,7 +63,23 @@ function normalizeAgentId(value: string | undefined): string { } function encodeRuntimeSessionName(state: CovenRuntimeSessionState): string { - return `coven:${Buffer.from(JSON.stringify(state), "utf8").toString("base64url")}`; + const prefix = "coven:"; + const safeState: CovenRuntimeSessionState = { + agent: normalizeAgentId(state.agent).slice(0, MAX_RUNTIME_AGENT_CHARS) || "codex", + mode: (state.mode.trim() || "prompt").slice(0, MAX_RUNTIME_MODE_CHARS), + ...(state.sessionMode + ? { sessionMode: state.sessionMode.trim().slice(0, MAX_RUNTIME_MODE_CHARS) } + : {}), + }; + const encoded = Buffer.from(JSON.stringify(safeState), "utf8").toString("base64url"); + const value = `${prefix}${encoded}`; + if (Buffer.byteLength(value, "utf8") > prefix.length + MAX_RUNTIME_SESSION_NAME_BYTES) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + "Coven runtime session metadata is too large.", + ); + } + return value; } function decodeRuntimeSessionName(value: string): CovenRuntimeSessionState | null { @@ -236,19 +254,6 @@ function terminalStatusEvent(session: CovenSessionRecord): AcpRuntimeEvent { }; } -function pathIsInside(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function realpathIfExists(filePath: string): string | null { - try { - return fs.realpathSync.native(filePath); - } catch { - return null; - } -} - export class CovenAcpRuntime implements AcpRuntime { private readonly config: ResolvedCovenPluginConfig; private readonly client: CovenClient; @@ -282,7 +287,6 @@ export class CovenAcpRuntime implements AcpRuntime { agent, mode: "prompt", sessionMode: input.mode, - ...(input.cwd ? { cwd: input.cwd } : {}), }), ...(input.cwd ? { cwd: input.cwd } : {}), };