mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
chore: remove coven extension
This commit is contained in:
parent
dc96886378
commit
f7797ca62b
13 changed files with 0 additions and 2753 deletions
|
|
@ -11,11 +11,6 @@ Docs: https://docs.openclaw.ai
|
|||
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
|
||||
- Gateway/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd.
|
||||
- Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd.
|
||||
- ACP/runtime: add an opt-in bundled Coven backend extension that routes ACP coding sessions through a local Coven daemon when `acp.backend="coven"`, while preserving the existing ACPX backend as the default fallback path. Thanks @BunsDev.
|
||||
|
||||
### Fixes
|
||||
|
||||
- ACP/runtime: harden the opt-in Coven backend with workspace-confined launch paths, home-expanded Coven socket config, bounded socket responses, sanitized daemon output, and controlled polling failure handling. Thanks @BunsDev.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -186,77 +186,6 @@ Override the command or version in plugin config:
|
|||
|
||||
See [Plugins](/tools/plugin).
|
||||
|
||||
## Optional Coven backend
|
||||
|
||||
OpenClaw can also register a bundled, opt-in `coven` ACP backend for operators
|
||||
who want ACP coding sessions supervised by a local [Coven](https://github.com/OpenCoven/coven)
|
||||
daemon instead of launched directly through ACPX.
|
||||
|
||||
This is intentionally an extension, not a core runtime path:
|
||||
|
||||
- the default ACPX backend stays unchanged for normal installs;
|
||||
- Coven has its own daemon, socket, session store, harness mapping, and project
|
||||
boundary model;
|
||||
- the bridge can be enabled, disabled, configured, and reviewed independently
|
||||
through the plugin system; and
|
||||
- OpenClaw remains responsible for ACP session routing, chat bindings, task
|
||||
state, and fallback policy while Coven owns harness supervision.
|
||||
|
||||
Minimal opt-in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
acp: {
|
||||
enabled: true,
|
||||
backend: "coven",
|
||||
defaultAgent: "codex",
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
coven: {
|
||||
enabled: true,
|
||||
config: {
|
||||
// Optional. Defaults to ~/.coven. Environment variables are not used for this trust anchor.
|
||||
covenHome: "~/.coven",
|
||||
// Optional. Defaults to <covenHome>/coven.sock; overrides must resolve to that path.
|
||||
socketPath: "~/.coven/coven.sock",
|
||||
// Optional. Defaults to false; enable only when direct ACP fallback is acceptable.
|
||||
allowFallback: false,
|
||||
// Optional. Used only when allowFallback is true.
|
||||
fallbackBackend: "acpx",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When selected, OpenClaw checks Coven daemon health over the configured Unix
|
||||
socket before launching. A successful launch creates a Coven session and records
|
||||
the Coven session id in the ACP runtime handle. If the health check or launch
|
||||
fails, OpenClaw fails closed by default so `acp.backend="coven"` cannot silently
|
||||
downgrade to direct ACP execution. Set `allowFallback: true` only when direct
|
||||
ACP fallback is an explicit, acceptable operator choice.
|
||||
|
||||
For path safety, `~` in `covenHome` and `socketPath` expands to the current
|
||||
user home directory, and configured Coven paths must be absolute after that
|
||||
expansion. OpenClaw rejects workspace-relative Coven daemon paths because the
|
||||
daemon socket is a local user trust anchor, not repository-controlled state.
|
||||
`socketPath` must resolve to `<covenHome>/coven.sock`; OpenClaw does not allow
|
||||
arbitrary Coven socket filenames because the daemon socket is the local trust
|
||||
anchor. Keep `covenHome` owned by the OpenClaw user and private (`0700`);
|
||||
OpenClaw rejects symlinked, shared-accessible, shared-writable, or non-socket
|
||||
Coven socket paths before connecting. The Coven backend currently requires Unix
|
||||
socket validation and fails closed on Windows rather than trusting a socket path
|
||||
whose owner and permissions cannot be validated by this plugin.
|
||||
|
||||
The default harness mapping sends known ACP agent ids such as `codex`, `claude`,
|
||||
`gemini`, and `opencode` to explicitly authorized Coven harness ids. Unknown
|
||||
ACP agent ids are rejected instead of being forwarded as harness names. Override
|
||||
`plugins.entries.coven.config.harnesses` only when your local Coven install uses
|
||||
custom harness names, and keep `acp.allowedAgents` aligned with the intended
|
||||
chat-exposed harness set.
|
||||
|
||||
### Automatic dependency install
|
||||
|
||||
When you install OpenClaw globally with `npm install -g openclaw`, the acpx
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import {
|
||||
registerAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createCovenPluginConfigSchema, resolveCovenPluginConfig } from "./src/config.js";
|
||||
import { CovenAcpRuntime, COVEN_BACKEND_ID } from "./src/runtime.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: COVEN_BACKEND_ID,
|
||||
name: "Coven ACP Runtime",
|
||||
description:
|
||||
"Opt-in ACP runtime backend that launches coding tasks through a local Coven daemon.",
|
||||
configSchema: () => createCovenPluginConfigSchema(),
|
||||
register(api) {
|
||||
api.registerService({
|
||||
id: "coven-runtime",
|
||||
async start(ctx) {
|
||||
const config = resolveCovenPluginConfig({
|
||||
rawConfig: api.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, logger: ctx.logger });
|
||||
registerAcpRuntimeBackend({ id: COVEN_BACKEND_ID, runtime });
|
||||
ctx.logger.info("coven ACP runtime backend registered");
|
||||
},
|
||||
async stop() {
|
||||
unregisterAcpRuntimeBackend(COVEN_BACKEND_ID);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
{
|
||||
"id": "coven",
|
||||
"enabledByDefault": false,
|
||||
"name": "Coven ACP Runtime",
|
||||
"description": "Opt-in ACP runtime backend that launches coding tasks through a local Coven daemon.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"covenHome": {
|
||||
"type": "string",
|
||||
"description": "Path to the Coven daemon state directory. Defaults to ~/.coven; environment variables are not used for this trust anchor."
|
||||
},
|
||||
"socketPath": {
|
||||
"type": "string",
|
||||
"description": "Path to the Coven daemon Unix socket. Defaults to <covenHome>/coven.sock; overrides must resolve to that fixed socket filename."
|
||||
},
|
||||
"allowFallback": {
|
||||
"type": "boolean",
|
||||
"description": "When true, fall back to fallbackBackend if Coven is unavailable or launch fails. Defaults to false."
|
||||
},
|
||||
"fallbackBackend": {
|
||||
"type": "string",
|
||||
"description": "ACP backend to use only when allowFallback is true. Defaults to acpx."
|
||||
},
|
||||
"pollIntervalMs": {
|
||||
"type": "number",
|
||||
"description": "Polling interval for Coven session events."
|
||||
},
|
||||
"harnesses": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Explicitly map additional OpenClaw ACP agent ids to authorized Coven harness ids. Unknown agent ids are rejected."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"name": "@openclaw/coven",
|
||||
"version": "2026.4.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw Coven ACP runtime bridge",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.26"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.4.26"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.25"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.26"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { __testing, CovenApiError, createCovenClient } from "./client.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-coven-client-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function withServer(
|
||||
handler: http.RequestListener,
|
||||
fn: (socketPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const socketPath = path.join(tmpDir, "coven.sock");
|
||||
const server = http.createServer(handler);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, () => resolve());
|
||||
});
|
||||
try {
|
||||
await fn(socketPath);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("createCovenClient", () => {
|
||||
it("parses daemon JSON over a Unix socket", async () => {
|
||||
await withServer(
|
||||
(_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, daemon: null }));
|
||||
},
|
||||
async (socketPath) => {
|
||||
await expect(createCovenClient(socketPath).health()).resolves.toEqual({
|
||||
ok: true,
|
||||
daemon: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("validates a real socket inside the configured socket root", async () => {
|
||||
await withServer(
|
||||
(_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, daemon: null }));
|
||||
},
|
||||
async (socketPath) => {
|
||||
await expect(
|
||||
createCovenClient(socketPath, { socketRoot: tmpDir }).health(),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
daemon: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the event cursor when listing events", async () => {
|
||||
await withServer(
|
||||
(req, res) => {
|
||||
expect(req.url).toBe("/events?sessionId=session-1&afterEventId=event-1");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("[]");
|
||||
},
|
||||
async (socketPath) => {
|
||||
await expect(
|
||||
createCovenClient(socketPath).listEvents("session-1", { afterEventId: "event-1" }),
|
||||
).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized event cursors before building the events URL", () => {
|
||||
expect(() =>
|
||||
createCovenClient("/tmp/coven.sock").listEvents("session-1", {
|
||||
afterEventId: "e".repeat(257),
|
||||
}),
|
||||
).toThrow(/event id is invalid/);
|
||||
});
|
||||
|
||||
it("wraps invalid daemon JSON in a typed API error", async () => {
|
||||
await withServer(
|
||||
(_req, res) => {
|
||||
res.end("{not json");
|
||||
},
|
||||
async (socketPath) => {
|
||||
await expect(createCovenClient(socketPath).health()).rejects.toBeInstanceOf(CovenApiError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects daemon responses above the response size limit", async () => {
|
||||
await withServer(
|
||||
(_req, res) => {
|
||||
res.end("x".repeat(1_000_001));
|
||||
},
|
||||
async (socketPath) => {
|
||||
await expect(createCovenClient(socketPath).health()).rejects.toThrow(/size limit/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects request bodies above the request size limit", async () => {
|
||||
await withServer(
|
||||
(_req, res) => {
|
||||
res.end("{}");
|
||||
},
|
||||
async (socketPath) => {
|
||||
await expect(
|
||||
createCovenClient(socketPath).launchSession({
|
||||
projectRoot: "/repo",
|
||||
cwd: "/repo",
|
||||
harness: "codex",
|
||||
prompt: "x".repeat(1_000_001),
|
||||
title: "Large prompt",
|
||||
}),
|
||||
).rejects.toThrow(/request exceeded size limit/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("revalidates socket paths before connecting", async () => {
|
||||
const covenHome = path.join(tmpDir, ".coven");
|
||||
await fs.mkdir(covenHome);
|
||||
await fs.chmod(covenHome, 0o700);
|
||||
const socketPath = path.join(covenHome, "coven.sock");
|
||||
await fs.symlink("/var/run/docker.sock", socketPath);
|
||||
|
||||
await expect(createCovenClient(socketPath, { socketRoot: covenHome }).health()).rejects.toThrow(
|
||||
/must not be a symlink/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a socket root that resolves through a symlink", async () => {
|
||||
const realHome = path.join(tmpDir, "real-coven");
|
||||
const symlinkHome = path.join(tmpDir, "symlink-coven");
|
||||
await fs.mkdir(realHome);
|
||||
await fs.chmod(realHome, 0o700);
|
||||
await fs.symlink(realHome, symlinkHome);
|
||||
|
||||
await expect(
|
||||
createCovenClient(path.join(symlinkHome, "coven.sock"), { socketRoot: symlinkHome }).health(),
|
||||
).rejects.toThrow(/covenHome must not be a symlink/);
|
||||
});
|
||||
|
||||
it("rejects missing socket roots with a validation error", async () => {
|
||||
const covenHome = path.join(tmpDir, "missing-coven");
|
||||
|
||||
await expect(
|
||||
createCovenClient(path.join(covenHome, "coven.sock"), { socketRoot: covenHome }).health(),
|
||||
).rejects.toThrow(/covenHome must exist/);
|
||||
});
|
||||
|
||||
it("rejects a group or world writable socket root", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const covenHome = path.join(tmpDir, ".coven");
|
||||
await fs.mkdir(covenHome);
|
||||
await fs.chmod(covenHome, 0o777);
|
||||
|
||||
await expect(
|
||||
createCovenClient(path.join(covenHome, "coven.sock"), { socketRoot: covenHome }).health(),
|
||||
).rejects.toThrow(/covenHome must not be group or world writable/);
|
||||
});
|
||||
|
||||
it("rejects socket paths that are not Unix sockets", async () => {
|
||||
const covenHome = path.join(tmpDir, ".coven");
|
||||
await fs.mkdir(covenHome);
|
||||
await fs.chmod(covenHome, 0o700);
|
||||
const socketPath = path.join(covenHome, "coven.sock");
|
||||
await fs.writeFile(socketPath, "");
|
||||
|
||||
await expect(createCovenClient(socketPath, { socketRoot: covenHome }).health()).rejects.toThrow(
|
||||
/must be a Unix socket/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects socket path overrides even when they are inside covenHome", async () => {
|
||||
const covenHome = path.join(tmpDir, ".coven");
|
||||
await fs.mkdir(covenHome);
|
||||
await fs.chmod(covenHome, 0o700);
|
||||
const socketPath = path.join(covenHome, "other.sock");
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, daemon: null }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, () => resolve());
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
createCovenClient(socketPath, { socketRoot: covenHome }).health(),
|
||||
).rejects.toThrow(/socketPath must be <covenHome>\/coven\.sock/);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed instead of bypassing socket validation on Windows", () => {
|
||||
expect(() =>
|
||||
__testing.validateSocketPathForUse(
|
||||
path.join(tmpDir, ".coven", "coven.sock"),
|
||||
path.join(tmpDir, ".coven"),
|
||||
"win32",
|
||||
),
|
||||
).toThrow(/not supported on Windows/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
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;
|
||||
projectRoot: string;
|
||||
harness: string;
|
||||
title: string;
|
||||
status: string;
|
||||
exitCode: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CovenEventRecord = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
kind: string;
|
||||
payloadJson: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CovenHealthResponse = {
|
||||
ok: boolean;
|
||||
daemon?: {
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
socket: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type LaunchCovenSessionInput = {
|
||||
projectRoot: string;
|
||||
cwd: string;
|
||||
harness: string;
|
||||
prompt: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface CovenClient {
|
||||
health(signal?: AbortSignal): Promise<CovenHealthResponse>;
|
||||
launchSession(input: LaunchCovenSessionInput, signal?: AbortSignal): Promise<CovenSessionRecord>;
|
||||
getSession(sessionId: string, signal?: AbortSignal): Promise<CovenSessionRecord>;
|
||||
listEvents(
|
||||
sessionId: string,
|
||||
options?: CovenListEventsOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CovenEventRecord[]>;
|
||||
sendInput(sessionId: string, data: string, signal?: AbortSignal): Promise<void>;
|
||||
killSession(sessionId: string, signal?: AbortSignal): Promise<void>;
|
||||
}
|
||||
|
||||
export type CovenListEventsOptions = {
|
||||
afterEventId?: string;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
socketPath: string;
|
||||
socketRoot?: string;
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type HttpResponse = {
|
||||
status: number;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type SocketFingerprint = {
|
||||
dev: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
};
|
||||
|
||||
export class CovenApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
|
||||
constructor(status: number, body: string) {
|
||||
super(`Coven API returned HTTP ${status || "unknown"}`);
|
||||
this.name = "CovenApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_REQUEST_BYTES = 1_000_000;
|
||||
const MAX_RESPONSE_BYTES = 1_000_000;
|
||||
const DEFAULT_SOCKET_FILENAME = "coven.sock";
|
||||
const SAFE_QUERY_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
|
||||
const MAX_QUERY_ID_CHARS = 256;
|
||||
|
||||
function statExistingPath(filePath: string, label: string): fs.Stats {
|
||||
try {
|
||||
return fs.statSync(filePath);
|
||||
} catch {
|
||||
throw new Error(`${label} must exist`);
|
||||
}
|
||||
}
|
||||
|
||||
function realpathExistingPath(filePath: string, label: string): string {
|
||||
try {
|
||||
return fs.realpathSync.native(filePath);
|
||||
} catch {
|
||||
throw new Error(`${label} must exist`);
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprintSocket(stat: fs.Stats): SocketFingerprint {
|
||||
return {
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
mode: stat.mode,
|
||||
uid: stat.uid,
|
||||
gid: stat.gid,
|
||||
};
|
||||
}
|
||||
|
||||
function socketFingerprintMatches(left: SocketFingerprint, right: SocketFingerprint): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mode === right.mode &&
|
||||
left.uid === right.uid &&
|
||||
left.gid === right.gid
|
||||
);
|
||||
}
|
||||
|
||||
function validateSocketPathForUse(
|
||||
socketPath: string,
|
||||
socketRoot: string | undefined,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SocketFingerprint | null {
|
||||
if (!socketRoot) {
|
||||
return null;
|
||||
}
|
||||
validateSocketPlatform(platform);
|
||||
const socketRootLstat = lstatIfExists(socketRoot);
|
||||
if (socketRootLstat?.isSymbolicLink()) {
|
||||
throw new Error("Coven covenHome must not be a symlink");
|
||||
}
|
||||
const socketRootStat = statExistingPath(socketRoot, "Coven covenHome");
|
||||
validateSocketOwnerAndMode(socketRootStat, "Coven covenHome", platform);
|
||||
validatePrivateDirectory(socketRootStat, "Coven covenHome", platform);
|
||||
const expectedSocketPath = path.resolve(socketRoot, DEFAULT_SOCKET_FILENAME);
|
||||
if (path.resolve(socketPath) !== expectedSocketPath) {
|
||||
throw new Error("Coven socketPath must be <covenHome>/coven.sock");
|
||||
}
|
||||
|
||||
const socketStat = lstatIfExists(socketPath);
|
||||
if (socketStat?.isSymbolicLink()) {
|
||||
throw new Error("Coven socketPath must not be a symlink");
|
||||
}
|
||||
const resolvedSocketStat = statExistingPath(socketPath, "Coven socketPath");
|
||||
if (!resolvedSocketStat.isSocket()) {
|
||||
throw new Error("Coven socketPath must be a Unix socket");
|
||||
}
|
||||
validateSocketOwnerAndMode(resolvedSocketStat, "Coven socketPath", platform);
|
||||
|
||||
const realSocketRoot = realpathExistingPath(socketRoot, "Coven covenHome");
|
||||
const realSocketDir = realpathExistingPath(
|
||||
path.dirname(socketPath),
|
||||
"Coven socketPath directory",
|
||||
);
|
||||
const socketDirStat = statExistingPath(path.dirname(socketPath), "Coven socketPath directory");
|
||||
validateSocketOwnerAndMode(socketDirStat, "Coven socketPath directory", platform);
|
||||
validatePrivateDirectory(socketDirStat, "Coven socketPath directory", platform);
|
||||
if (!pathIsInside(realSocketRoot, realSocketDir)) {
|
||||
throw new Error("Coven socketPath must stay inside covenHome");
|
||||
}
|
||||
const realSocketPath = realpathExistingPath(socketPath, "Coven socketPath");
|
||||
if (!pathIsInside(realSocketRoot, realSocketPath)) {
|
||||
throw new Error("Coven socketPath must stay inside covenHome");
|
||||
}
|
||||
return fingerprintSocket(resolvedSocketStat);
|
||||
}
|
||||
|
||||
function validateSocketPlatform(platform: NodeJS.Platform): void {
|
||||
if (platform === "win32") {
|
||||
throw new Error("Coven Unix socket validation is not supported on Windows");
|
||||
}
|
||||
}
|
||||
|
||||
function requireSafeQueryId(input: string, label: string): string {
|
||||
const value = input.trim();
|
||||
if (!value || value.length > MAX_QUERY_ID_CHARS || !SAFE_QUERY_ID_REGEX.test(value)) {
|
||||
throw new Error(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSocketOwnerAndMode(
|
||||
stat: fs.Stats,
|
||||
label: string,
|
||||
platform: NodeJS.Platform,
|
||||
): void {
|
||||
validateSocketPlatform(platform);
|
||||
const currentUid = typeof process.getuid === "function" ? process.getuid() : null;
|
||||
if (currentUid != null && stat.uid !== currentUid) {
|
||||
throw new Error(`${label} must be owned by the current user`);
|
||||
}
|
||||
if ((stat.mode & 0o022) !== 0) {
|
||||
throw new Error(`${label} must not be group or world writable`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePrivateDirectory(stat: fs.Stats, label: string, platform: NodeJS.Platform): void {
|
||||
validateSocketPlatform(platform);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`${label} must be a directory`);
|
||||
}
|
||||
if ((stat.mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} must not be group or world accessible`);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRequestBody(body: unknown): { text: string; byteLength: number } {
|
||||
if (body === undefined) {
|
||||
return { text: "", byteLength: 0 };
|
||||
}
|
||||
const text = JSON.stringify(body) ?? "";
|
||||
const byteLength = Buffer.byteLength(text, "utf8");
|
||||
if (byteLength > MAX_REQUEST_BYTES) {
|
||||
throw new Error("Coven API request exceeded size limit");
|
||||
}
|
||||
return { text, byteLength };
|
||||
}
|
||||
|
||||
function errorToError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function socketThatFailsWith(error: unknown): net.Socket {
|
||||
const socket = new net.Socket();
|
||||
queueMicrotask(() => socket.destroy(errorToError(error)));
|
||||
return socket;
|
||||
}
|
||||
|
||||
function requestOverSocket(options: RequestOptions): Promise<HttpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(options.signal.reason ?? new Error("request aborted"));
|
||||
return;
|
||||
}
|
||||
let requestBody = "";
|
||||
let requestBodyBytes = 0;
|
||||
let socketFingerprint: SocketFingerprint | null = null;
|
||||
try {
|
||||
socketFingerprint = validateSocketPathForUse(options.socketPath, options.socketRoot);
|
||||
const serialized = serializeRequestBody(options.body);
|
||||
requestBody = serialized.text;
|
||||
requestBodyBytes = serialized.byteLength;
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let body = "";
|
||||
let totalBytes = 0;
|
||||
|
||||
const settle = (fn: () => void, req?: http.ClientRequest) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
req?.destroy();
|
||||
fn();
|
||||
};
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
createConnection: () => {
|
||||
try {
|
||||
const beforeConnect = validateSocketPathForUse(options.socketPath, options.socketRoot);
|
||||
const socket = net.createConnection({ path: options.socketPath });
|
||||
socket.once("connect", () => {
|
||||
try {
|
||||
const afterConnect = validateSocketPathForUse(
|
||||
options.socketPath,
|
||||
options.socketRoot,
|
||||
);
|
||||
const expected = beforeConnect ?? socketFingerprint;
|
||||
if (expected && afterConnect && !socketFingerprintMatches(expected, afterConnect)) {
|
||||
socket.destroy(new Error("Coven socketPath changed during connection"));
|
||||
}
|
||||
} catch (error) {
|
||||
socket.destroy(errorToError(error));
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
} catch (error) {
|
||||
return socketThatFailsWith(error);
|
||||
}
|
||||
},
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
headers: {
|
||||
Host: "coven",
|
||||
Connection: "close",
|
||||
...(requestBody
|
||||
? {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": requestBodyBytes,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
signal: options.signal,
|
||||
},
|
||||
(res) => {
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk: string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
totalBytes += Buffer.byteLength(chunk);
|
||||
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||
settle(() => reject(new Error("Coven API response exceeded size limit")), req);
|
||||
return;
|
||||
}
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
settle(() =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body,
|
||||
}),
|
||||
);
|
||||
});
|
||||
res.on("error", (error) => settle(() => reject(error), req));
|
||||
},
|
||||
);
|
||||
req.setTimeout(DEFAULT_REQUEST_TIMEOUT_MS, () => {
|
||||
settle(() => reject(new Error("Coven API request timed out")), req);
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settle(() => reject(error));
|
||||
});
|
||||
req.end(requestBody);
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(options: RequestOptions): Promise<T> {
|
||||
const response = await requestOverSocket(options);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new CovenApiError(response.status, response.body);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.body || "null") as T;
|
||||
} catch (error) {
|
||||
throw new CovenApiError(response.status, `Invalid JSON response: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCovenClient(
|
||||
socketPath: string,
|
||||
clientOptions: { socketRoot?: string } = {},
|
||||
): CovenClient {
|
||||
return {
|
||||
health(signal) {
|
||||
return requestJson<CovenHealthResponse>({
|
||||
socketPath,
|
||||
socketRoot: clientOptions.socketRoot,
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
signal,
|
||||
});
|
||||
},
|
||||
launchSession(input, signal) {
|
||||
return requestJson<CovenSessionRecord>({
|
||||
socketPath,
|
||||
socketRoot: clientOptions.socketRoot,
|
||||
method: "POST",
|
||||
path: "/sessions",
|
||||
body: input,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
getSession(sessionId, signal) {
|
||||
return requestJson<CovenSessionRecord>({
|
||||
socketPath,
|
||||
socketRoot: clientOptions.socketRoot,
|
||||
method: "GET",
|
||||
path: `/sessions/${encodeURIComponent(sessionId)}`,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
listEvents(sessionId, options, signal) {
|
||||
const params = new URLSearchParams({
|
||||
sessionId: requireSafeQueryId(sessionId, "Coven session id"),
|
||||
});
|
||||
const afterEventId = options?.afterEventId?.trim();
|
||||
if (afterEventId) {
|
||||
params.set("afterEventId", requireSafeQueryId(afterEventId, "Coven event id"));
|
||||
}
|
||||
return requestJson<CovenEventRecord[]>({
|
||||
socketPath,
|
||||
socketRoot: clientOptions.socketRoot,
|
||||
method: "GET",
|
||||
path: `/events?${params.toString()}`,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
async sendInput(sessionId, data, signal) {
|
||||
await requestJson<unknown>({
|
||||
socketPath,
|
||||
socketRoot: clientOptions.socketRoot,
|
||||
method: "POST",
|
||||
path: `/sessions/${encodeURIComponent(sessionId)}/input`,
|
||||
body: { data },
|
||||
signal,
|
||||
});
|
||||
},
|
||||
async killSession(sessionId, signal) {
|
||||
await requestJson<unknown>({
|
||||
socketPath,
|
||||
socketRoot: clientOptions.socketRoot,
|
||||
method: "POST",
|
||||
path: `/sessions/${encodeURIComponent(sessionId)}/kill`,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
validateSocketPathForUse,
|
||||
};
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveCovenPluginConfig } from "./config.js";
|
||||
|
||||
const OLD_COVEN_HOME = process.env.COVEN_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (OLD_COVEN_HOME === undefined) {
|
||||
delete process.env.COVEN_HOME;
|
||||
} else {
|
||||
process.env.COVEN_HOME = OLD_COVEN_HOME;
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveCovenPluginConfig", () => {
|
||||
it("expands tilde paths before resolving Coven home and socket path", () => {
|
||||
const resolved = resolveCovenPluginConfig({
|
||||
rawConfig: {
|
||||
covenHome: "~/.coven",
|
||||
socketPath: "~/.coven/coven.sock",
|
||||
},
|
||||
workspaceDir: "/repo",
|
||||
});
|
||||
|
||||
expect(resolved.covenHome).toBe(path.join(os.homedir(), ".coven"));
|
||||
expect(resolved.socketPath).toBe(path.join(os.homedir(), ".coven", "coven.sock"));
|
||||
});
|
||||
|
||||
it("rejects relative Coven paths instead of trusting workspace contents", () => {
|
||||
expect(() =>
|
||||
resolveCovenPluginConfig({
|
||||
rawConfig: {
|
||||
covenHome: ".coven",
|
||||
socketPath: ".coven/coven.sock",
|
||||
},
|
||||
workspaceDir: "/repo",
|
||||
}),
|
||||
).toThrow(/covenHome must be absolute/);
|
||||
});
|
||||
|
||||
it("rejects socket paths outside covenHome", () => {
|
||||
expect(() =>
|
||||
resolveCovenPluginConfig({
|
||||
rawConfig: {
|
||||
covenHome: "~/.coven",
|
||||
socketPath: "/var/run/docker.sock",
|
||||
},
|
||||
workspaceDir: "/repo",
|
||||
}),
|
||||
).toThrow(/socketPath must stay inside covenHome/);
|
||||
});
|
||||
|
||||
it("rejects alternate socket filenames inside covenHome", () => {
|
||||
expect(() =>
|
||||
resolveCovenPluginConfig({
|
||||
rawConfig: {
|
||||
covenHome: "~/.coven",
|
||||
socketPath: "~/.coven/other.sock",
|
||||
},
|
||||
workspaceDir: "/repo",
|
||||
}),
|
||||
).toThrow(/socketPath overrides are not supported/);
|
||||
});
|
||||
|
||||
it("rejects socket paths that are symlinks", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-coven-config-"));
|
||||
const covenHome = path.join(workspaceDir, ".coven");
|
||||
await fs.mkdir(covenHome);
|
||||
const socketPath = path.join(covenHome, "coven.sock");
|
||||
await fs.symlink("/var/run/docker.sock", socketPath);
|
||||
try {
|
||||
expect(() =>
|
||||
resolveCovenPluginConfig({
|
||||
rawConfig: {
|
||||
covenHome,
|
||||
socketPath,
|
||||
},
|
||||
workspaceDir,
|
||||
}),
|
||||
).toThrow(/must not be a symlink/);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects covenHome when it is a symlink", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-coven-config-"));
|
||||
const realHome = path.join(workspaceDir, "real-coven");
|
||||
const symlinkHome = path.join(workspaceDir, "symlink-coven");
|
||||
await fs.mkdir(realHome);
|
||||
await fs.symlink(realHome, symlinkHome);
|
||||
try {
|
||||
expect(() =>
|
||||
resolveCovenPluginConfig({
|
||||
rawConfig: {
|
||||
covenHome: symlinkHome,
|
||||
},
|
||||
workspaceDir,
|
||||
}),
|
||||
).toThrow(/covenHome must not be a symlink/);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores COVEN_HOME when resolving the socket trust anchor", () => {
|
||||
process.env.COVEN_HOME = "~/.custom-coven";
|
||||
|
||||
const resolved = resolveCovenPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: "/repo",
|
||||
});
|
||||
|
||||
expect(resolved.covenHome).toBe(path.join(os.homedir(), ".coven"));
|
||||
expect(resolved.socketPath).toBe(path.join(os.homedir(), ".coven", "coven.sock"));
|
||||
expect(resolved.allowFallback).toBe(false);
|
||||
});
|
||||
|
||||
it("only enables fallback when configured explicitly", () => {
|
||||
const resolved = resolveCovenPluginConfig({
|
||||
rawConfig: { allowFallback: true },
|
||||
workspaceDir: "/repo",
|
||||
});
|
||||
|
||||
expect(resolved.allowFallback).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
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;
|
||||
socketPath?: string;
|
||||
allowFallback?: boolean;
|
||||
fallbackBackend?: string;
|
||||
pollIntervalMs?: number;
|
||||
harnesses?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ResolvedCovenPluginConfig = {
|
||||
covenHome: string;
|
||||
socketPath: string;
|
||||
workspaceDir: string;
|
||||
allowFallback: boolean;
|
||||
fallbackBackend: string;
|
||||
pollIntervalMs: number;
|
||||
harnesses: Record<string, string>;
|
||||
};
|
||||
|
||||
const DEFAULT_FALLBACK_BACKEND = "acpx";
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
const DEFAULT_SOCKET_FILENAME = "coven.sock";
|
||||
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
|
||||
export const CovenPluginConfigSchema = z.strictObject({
|
||||
covenHome: nonEmptyString.optional(),
|
||||
socketPath: nonEmptyString.optional(),
|
||||
allowFallback: z.boolean().optional(),
|
||||
fallbackBackend: nonEmptyString.optional(),
|
||||
pollIntervalMs: z.number().min(25).max(10_000).optional(),
|
||||
harnesses: z.record(z.string(), nonEmptyString).optional(),
|
||||
});
|
||||
|
||||
export function createCovenPluginConfigSchema() {
|
||||
return buildPluginConfigSchema(CovenPluginConfigSchema);
|
||||
}
|
||||
|
||||
function normalizeBackendId(value: string | undefined): string {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized || DEFAULT_FALLBACK_BACKEND;
|
||||
}
|
||||
|
||||
function expandTilde(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (trimmed.startsWith("~/")) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveConfiguredPath(raw: string, label: "covenHome" | "socketPath"): string {
|
||||
const expanded = expandTilde(raw);
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error(`Coven ${label} must be absolute`);
|
||||
}
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
function resolveCovenHome(raw: string | undefined): string {
|
||||
const fromConfig = raw?.trim();
|
||||
if (fromConfig) {
|
||||
return resolveConfiguredPath(fromConfig, "covenHome");
|
||||
}
|
||||
return path.join(os.homedir(), ".coven");
|
||||
}
|
||||
|
||||
function resolveSocketPath(covenHome: string, raw: string | undefined): string {
|
||||
if (lstatIfExists(covenHome)?.isSymbolicLink()) {
|
||||
throw new Error("Coven covenHome must not be a symlink");
|
||||
}
|
||||
const defaultSocketPath = path.join(covenHome, DEFAULT_SOCKET_FILENAME);
|
||||
const socketPath = raw?.trim() ? resolveConfiguredPath(raw, "socketPath") : defaultSocketPath;
|
||||
if (!pathIsInside(covenHome, socketPath)) {
|
||||
throw new Error("Coven socketPath must stay inside covenHome");
|
||||
}
|
||||
if (socketPath !== defaultSocketPath) {
|
||||
throw new Error("Coven socketPath overrides are not supported");
|
||||
}
|
||||
const socketStat = lstatIfExists(socketPath);
|
||||
if (socketStat?.isSymbolicLink()) {
|
||||
throw new Error("Coven socketPath must not be a symlink");
|
||||
}
|
||||
const realCovenHome = realpathIfExists(covenHome);
|
||||
const realSocketDir = realpathIfExists(path.dirname(socketPath));
|
||||
if (realCovenHome && realSocketDir && !pathIsInside(realCovenHome, realSocketDir)) {
|
||||
throw new Error("Coven socketPath must stay inside covenHome");
|
||||
}
|
||||
const realSocketPath = realpathIfExists(socketPath);
|
||||
if (realCovenHome && realSocketPath && !pathIsInside(realCovenHome, realSocketPath)) {
|
||||
throw new Error("Coven socketPath must stay inside covenHome");
|
||||
}
|
||||
return socketPath;
|
||||
}
|
||||
|
||||
function normalizeHarnesses(value: Record<string, string> | undefined): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value ?? {}).flatMap(([agent, harness]) => {
|
||||
const normalizedAgent = agent.trim().toLowerCase();
|
||||
const normalizedHarness = harness.trim();
|
||||
return normalizedAgent && normalizedHarness ? [[normalizedAgent, normalizedHarness]] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCovenPluginConfig(params: {
|
||||
rawConfig: unknown;
|
||||
workspaceDir?: string;
|
||||
}): ResolvedCovenPluginConfig {
|
||||
const parsed = CovenPluginConfigSchema.safeParse(params.rawConfig ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues[0]?.message ?? "invalid Coven plugin config");
|
||||
}
|
||||
const config = parsed.data as CovenPluginConfig;
|
||||
const workspaceDir = path.resolve(params.workspaceDir ?? process.cwd());
|
||||
const covenHome = resolveCovenHome(config.covenHome);
|
||||
return {
|
||||
covenHome,
|
||||
socketPath: resolveSocketPath(covenHome, config.socketPath),
|
||||
workspaceDir,
|
||||
allowFallback: config.allowFallback === true,
|
||||
fallbackBackend: normalizeBackendId(config.fallbackBackend),
|
||||
pollIntervalMs: config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
||||
harnesses: normalizeHarnesses(config.harnesses),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
expandTilde,
|
||||
resolveConfiguredPath,
|
||||
};
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,931 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
registerAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
type AcpRuntime,
|
||||
type AcpRuntimeEvent,
|
||||
type AcpRuntimeHandle,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CovenClient, CovenEventRecord, CovenSessionRecord } from "./client.js";
|
||||
import type { ResolvedCovenPluginConfig } from "./config.js";
|
||||
import { __testing, CovenAcpRuntime } from "./runtime.js";
|
||||
|
||||
const baseConfig: ResolvedCovenPluginConfig = {
|
||||
covenHome: "",
|
||||
socketPath: "",
|
||||
workspaceDir: "",
|
||||
allowFallback: false,
|
||||
fallbackBackend: "acpx",
|
||||
pollIntervalMs: 25,
|
||||
harnesses: {},
|
||||
};
|
||||
|
||||
let workspaceDir: string;
|
||||
let config: ResolvedCovenPluginConfig;
|
||||
|
||||
beforeEach(async () => {
|
||||
workspaceDir = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-coven-workspace-")),
|
||||
);
|
||||
const covenHome = path.join(workspaceDir, ".coven");
|
||||
await fs.mkdir(covenHome);
|
||||
config = {
|
||||
...baseConfig,
|
||||
covenHome,
|
||||
socketPath: path.join(covenHome, "coven.sock"),
|
||||
workspaceDir,
|
||||
};
|
||||
});
|
||||
|
||||
function session(overrides: Partial<CovenSessionRecord> = {}): CovenSessionRecord {
|
||||
return {
|
||||
id: "session-1",
|
||||
projectRoot: workspaceDir,
|
||||
harness: "codex",
|
||||
title: "Fix tests",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
createdAt: "2026-04-27T10:00:00Z",
|
||||
updatedAt: "2026-04-27T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function event(overrides: Partial<CovenEventRecord>): CovenEventRecord {
|
||||
return {
|
||||
id: "event-1",
|
||||
sessionId: "session-1",
|
||||
kind: "output",
|
||||
payloadJson: JSON.stringify({ data: "hello\n" }),
|
||||
createdAt: "2026-04-27T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeClient(overrides: Partial<CovenClient> = {}): CovenClient {
|
||||
return {
|
||||
health: vi.fn(async () => ({ ok: true, daemon: null })),
|
||||
launchSession: vi.fn(async () => session()),
|
||||
getSession: vi.fn(async () => session({ status: "completed", exitCode: 0 })),
|
||||
listEvents: vi.fn(async () => [
|
||||
event({ id: "event-1", kind: "output", payloadJson: JSON.stringify({ data: "hello\n" }) }),
|
||||
event({
|
||||
id: "event-2",
|
||||
kind: "exit",
|
||||
payloadJson: JSON.stringify({ status: "completed", exitCode: 0 }),
|
||||
}),
|
||||
]),
|
||||
sendInput: vi.fn(async () => undefined),
|
||||
killSession: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function collect(iterable: AsyncIterable<AcpRuntimeEvent>): Promise<AcpRuntimeEvent[]> {
|
||||
const events: AcpRuntimeEvent[] = [];
|
||||
for await (const item of iterable) {
|
||||
events.push(item);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function fallbackRuntime(): AcpRuntime {
|
||||
const handle: AcpRuntimeHandle = {
|
||||
sessionKey: "agent:codex:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "fallback-session",
|
||||
cwd: workspaceDir,
|
||||
};
|
||||
return {
|
||||
ensureSession: vi.fn(async () => handle),
|
||||
async *runTurn() {
|
||||
yield { type: "text_delta", text: "direct fallback\n", stream: "output" };
|
||||
yield { type: "done", stopReason: "complete" };
|
||||
},
|
||||
getStatus: vi.fn(async () => ({ summary: "fallback active" })),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
unregisterAcpRuntimeBackend("acpx");
|
||||
return fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("CovenAcpRuntime", () => {
|
||||
it("fails closed by default when Coven is unavailable", async () => {
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config,
|
||||
client: fakeClient({
|
||||
health: vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
}),
|
||||
).rejects.toThrow(/fallback is disabled/);
|
||||
});
|
||||
|
||||
it("falls back to the direct ACP backend when Coven is unavailable and fallback is enabled", async () => {
|
||||
const fallback = fallbackRuntime();
|
||||
registerAcpRuntimeBackend({ id: "acpx", runtime: fallback });
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config: { ...config, allowFallback: true },
|
||||
client: fakeClient({
|
||||
health: vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(fallback.ensureSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back when Coven health checks do not settle before the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fallback = fallbackRuntime();
|
||||
registerAcpRuntimeBackend({ id: "acpx", runtime: fallback });
|
||||
const client = fakeClient({
|
||||
health: vi.fn(
|
||||
async (signal?: AbortSignal) =>
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
signal?.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config: { ...config, allowFallback: true }, client });
|
||||
|
||||
const pending = runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
const handle = await pending;
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
});
|
||||
|
||||
it("launches a Coven session and streams output events to ACP", async () => {
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.launchSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectRoot: workspaceDir,
|
||||
cwd: workspaceDir,
|
||||
harness: "codex",
|
||||
prompt: "Fix tests",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(handle.backendSessionId).toBe("session-1");
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "status", text: "coven session session-1 started (codex)" }),
|
||||
expect.objectContaining({ type: "text_delta", text: "hello\n" }),
|
||||
expect.objectContaining({ type: "status", text: "coven session completed exitCode=0" }),
|
||||
expect.objectContaining({ type: "done", stopReason: "completed" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unknown ACP agent ids instead of forwarding them as Coven harness names", async () => {
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:attacker:test",
|
||||
agent: "attacker-harness",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
}),
|
||||
).rejects.toThrow(/Unknown or unauthorized ACP agent/);
|
||||
expect(client.health).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicit configured agent-to-harness mappings", async () => {
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config: { ...config, harnesses: { assistant: "codex" } },
|
||||
client,
|
||||
});
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:assistant:test",
|
||||
agent: "assistant",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
await collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.launchSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ harness: "codex" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes daemon-controlled harness fields in start status", async () => {
|
||||
const client = fakeClient({
|
||||
launchSession: vi.fn(async () =>
|
||||
session({
|
||||
harness: "\u001b[31mcodex\u001b[0m",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "status", text: "coven session session-1 started (codex)" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe daemon-controlled session ids before exposing handle fields", async () => {
|
||||
const client = fakeClient({
|
||||
launchSession: vi.fn(async () =>
|
||||
session({
|
||||
id: "\u001b]0;spoof\u0007session-1\r",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/session id is invalid/);
|
||||
expect(handle.backendSessionId).toBeUndefined();
|
||||
expect(handle.agentSessionId).toBeUndefined();
|
||||
expect(client.killSession).toHaveBeenCalledWith("\u001b]0;spoof\u0007session-1\r", undefined);
|
||||
});
|
||||
|
||||
it("kills an already-launched Coven session before falling back on unsafe session ids", async () => {
|
||||
const fallback = fallbackRuntime();
|
||||
registerAcpRuntimeBackend({ id: "acpx", runtime: fallback });
|
||||
const client = fakeClient({
|
||||
launchSession: vi.fn(async () => session({ id: "bad\nsession" })),
|
||||
killSession: vi.fn(async () => undefined),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config: { ...config, allowFallback: true }, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.killSession).toHaveBeenCalledWith("bad\nsession", undefined);
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "text_delta", text: "direct fallback\n" }),
|
||||
expect.objectContaining({ type: "done", stopReason: "complete" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed without launching Coven when prompts exceed the Coven request limit", async () => {
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "x".repeat(500_001),
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/fallback is disabled/);
|
||||
|
||||
expect(client.launchSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back on oversized prompts when fallback is explicitly enabled", async () => {
|
||||
const fallback = fallbackRuntime();
|
||||
registerAcpRuntimeBackend({ id: "acpx", runtime: fallback });
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({ config: { ...config, allowFallback: true }, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "x".repeat(500_001),
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.launchSession).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "text_delta", text: "direct fallback\n" }),
|
||||
expect.objectContaining({ type: "done", stopReason: "complete" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores cwd embedded in runtimeSessionName when launching Coven sessions", async () => {
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
handle.runtimeSessionName = `coven:${Buffer.from(
|
||||
JSON.stringify({
|
||||
agent: "codex",
|
||||
mode: "prompt",
|
||||
cwd: "/tmp/attacker",
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url")}`;
|
||||
|
||||
await collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.launchSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectRoot: workspaceDir,
|
||||
cwd: workspaceDir,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Coven handles whose cwd is outside the configured workspace", async () => {
|
||||
const runtime = new CovenAcpRuntime({ config, client: fakeClient() });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
handle.cwd = "/tmp/attacker";
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/outside workspace/);
|
||||
});
|
||||
|
||||
it("rejects Coven cwd symlinks that resolve outside the workspace", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-coven-workspace-"));
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-coven-outside-"));
|
||||
const symlinkPath = path.join(workspaceDir, "outside");
|
||||
await fs.symlink(outsideDir, symlinkPath);
|
||||
try {
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config: { ...config, workspaceDir },
|
||||
client: fakeClient(),
|
||||
});
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: symlinkPath,
|
||||
});
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/outside workspace/);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requests incremental events after the last processed Coven event", async () => {
|
||||
const client = fakeClient({
|
||||
listEvents: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
event({
|
||||
id: "event-1",
|
||||
kind: "output",
|
||||
payloadJson: JSON.stringify({ data: "hello\n" }),
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
event({
|
||||
id: "event-2",
|
||||
kind: "exit",
|
||||
payloadJson: JSON.stringify({ status: "completed", exitCode: 0 }),
|
||||
}),
|
||||
]),
|
||||
getSession: vi.fn(async () => session({ status: "running" })),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
await collect(
|
||||
runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" }),
|
||||
);
|
||||
|
||||
expect(client.listEvents).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"session-1",
|
||||
{
|
||||
afterEventId: "event-1",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails and kills the Coven session when the daemon returns an unsafe event id", async () => {
|
||||
const client = fakeClient({
|
||||
listEvents: vi.fn(async () => [
|
||||
event({
|
||||
id: "e".repeat(257),
|
||||
kind: "output",
|
||||
payloadJson: JSON.stringify({ data: "hello\n" }),
|
||||
}),
|
||||
]),
|
||||
killSession: vi.fn(async () => undefined),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" }),
|
||||
);
|
||||
|
||||
expect(client.killSession).toHaveBeenCalledWith("session-1", undefined);
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "status", text: "coven session session-1 started (codex)" }),
|
||||
expect.objectContaining({ type: "status", text: "coven session polling failed" }),
|
||||
expect.objectContaining({ type: "done", stopReason: "error" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps malformed runtime poll intervals before sleeping", async () => {
|
||||
const sleep = vi.fn(async () => undefined);
|
||||
const client = fakeClient({
|
||||
listEvents: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
event({
|
||||
id: "event-1",
|
||||
kind: "exit",
|
||||
payloadJson: JSON.stringify({ status: "completed", exitCode: 0 }),
|
||||
}),
|
||||
]),
|
||||
getSession: vi.fn(async () => session({ status: "running" })),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config: { ...config, pollIntervalMs: 0 },
|
||||
client,
|
||||
sleep,
|
||||
});
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
await collect(
|
||||
runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" }),
|
||||
);
|
||||
|
||||
expect(sleep).toHaveBeenCalledWith(25, undefined);
|
||||
});
|
||||
|
||||
it("fails the turn when the daemon returns too many events in one poll", async () => {
|
||||
const client = fakeClient({
|
||||
listEvents: vi.fn(async () =>
|
||||
Array.from({ length: 600 }, (_, index) =>
|
||||
event({
|
||||
id: `event-${index}`,
|
||||
kind: "output",
|
||||
payloadJson: JSON.stringify({ data: `line-${index}\n` }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
killSession: vi.fn(async () => undefined),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" }),
|
||||
);
|
||||
|
||||
expect(client.killSession).toHaveBeenCalledWith("session-1", undefined);
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "status", text: "coven session session-1 started (codex)" }),
|
||||
expect.objectContaining({ type: "status", text: "coven session polling failed" }),
|
||||
expect.objectContaining({ type: "done", stopReason: "error" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts Coven polling failures into controlled terminal events", async () => {
|
||||
const client = fakeClient({
|
||||
listEvents: vi.fn(async () => {
|
||||
throw new Error("bad json");
|
||||
}),
|
||||
killSession: vi.fn(async () => undefined),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" }),
|
||||
);
|
||||
|
||||
expect(client.killSession).toHaveBeenCalledWith("session-1", undefined);
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "status", text: "coven session session-1 started (codex)" }),
|
||||
expect.objectContaining({ type: "status", text: "coven session polling failed" }),
|
||||
expect.objectContaining({ type: "done", stopReason: "error" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("sanitizes Coven polling errors before logging", async () => {
|
||||
const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
const client = fakeClient({
|
||||
listEvents: vi.fn(async () => {
|
||||
throw new Error("\u001b]0;spoof\u0007bad\r\njson");
|
||||
}),
|
||||
killSession: vi.fn(async () => undefined),
|
||||
});
|
||||
const runtime = new CovenAcpRuntime({ config, client, logger });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
await collect(
|
||||
runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" }),
|
||||
);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith("coven polling failed: Error: bad json");
|
||||
});
|
||||
|
||||
it("strips terminal escape and control characters from Coven output", () => {
|
||||
expect(
|
||||
__testing.sanitizeTerminalText(
|
||||
"\u001b]0;spoof\u0007hi\u001b[31m!\u001b[0m\u001b7\u001bc\u202e\r\n",
|
||||
),
|
||||
).toBe("hi!\n");
|
||||
});
|
||||
|
||||
it("sanitizes prompt-derived session titles", () => {
|
||||
expect(__testing.titleFromPrompt("\u001b]0;spoof\u0007Fix\u001b[31m tests\r\nnow")).toBe(
|
||||
"Fix tests now",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes untrusted Coven exit status into bounded stop reasons", () => {
|
||||
expect(__testing.normalizeStopReason("completed")).toBe("completed");
|
||||
expect(__testing.normalizeStopReason("killed")).toBe("cancelled");
|
||||
expect(__testing.normalizeStopReason("refusal")).toBe("completed");
|
||||
|
||||
expect(
|
||||
__testing.eventToRuntimeEvents(
|
||||
event({
|
||||
kind: "exit",
|
||||
payloadJson: JSON.stringify({ status: "refusal", exitCode: 0 }),
|
||||
}),
|
||||
),
|
||||
).toContainEqual(expect.objectContaining({ type: "done", stopReason: "completed" }));
|
||||
});
|
||||
|
||||
it("guards daemon exitCode types before rendering terminal status text", () => {
|
||||
expect(
|
||||
__testing.terminalStatusEvent(
|
||||
session({ status: "completed", exitCode: "\u001b[31m1" as unknown as number }),
|
||||
),
|
||||
).toEqual({
|
||||
type: "status",
|
||||
text: "coven session completed",
|
||||
tag: "session_info_update",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops oversized daemon event payloads before parsing", () => {
|
||||
expect(
|
||||
__testing.eventToRuntimeEvents(
|
||||
event({
|
||||
kind: "output",
|
||||
payloadJson: JSON.stringify({ data: "x".repeat(64_001) }),
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects oversized Coven runtime session metadata", () => {
|
||||
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),
|
||||
});
|
||||
|
||||
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 {
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config: { ...config, workspaceDir },
|
||||
client: fakeClient(),
|
||||
});
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: path.join(workspaceDir, "missing"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/outside workspace/);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects Coven cwd paths that are not directories", async () => {
|
||||
const filePath = path.join(workspaceDir, "not-a-directory");
|
||||
await fs.writeFile(filePath, "not a directory");
|
||||
const runtime = new CovenAcpRuntime({ config, client: fakeClient() });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: filePath,
|
||||
});
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
runtime.runTurn({
|
||||
handle,
|
||||
text: "Fix tests",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/cwd must be a directory/);
|
||||
});
|
||||
|
||||
it("does not trust persisted backendSessionId without an active tracked Coven session", async () => {
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle: AcpRuntimeHandle = {
|
||||
sessionKey: "agent:codex:test",
|
||||
backend: "coven",
|
||||
runtimeSessionName: __testing.encodeRuntimeSessionName({
|
||||
agent: "codex",
|
||||
mode: "prompt",
|
||||
}),
|
||||
cwd: workspaceDir,
|
||||
backendSessionId: "attacker-session",
|
||||
};
|
||||
|
||||
await expect(runtime.getStatus({ handle })).resolves.toEqual({
|
||||
summary: "coven runtime ready",
|
||||
});
|
||||
await expect(runtime.cancel({ handle })).resolves.toBeUndefined();
|
||||
await expect(runtime.close({ handle, reason: "user" })).resolves.toBeUndefined();
|
||||
expect(client.getSession).not.toHaveBeenCalledWith("attacker-session", undefined);
|
||||
expect(client.killSession).not.toHaveBeenCalledWith("attacker-session", undefined);
|
||||
});
|
||||
|
||||
it("rejects backendSessionId values that conflict with the active tracked Coven session", async () => {
|
||||
const client = fakeClient();
|
||||
const runtime = new CovenAcpRuntime({ config, client });
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const turn = runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" });
|
||||
const iterator = turn[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
handle.backendSessionId = "attacker-session";
|
||||
|
||||
await expect(runtime.getStatus({ handle })).rejects.toThrow(/does not match/);
|
||||
await expect(runtime.cancel({ handle })).rejects.toThrow(/does not match/);
|
||||
await expect(runtime.close({ handle, reason: "user" })).rejects.toThrow(/does not match/);
|
||||
expect(client.getSession).not.toHaveBeenCalledWith("attacker-session", undefined);
|
||||
expect(client.killSession).not.toHaveBeenCalledWith("attacker-session", undefined);
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
it("preserves direct fallback when Coven launch fails after detection", async () => {
|
||||
const fallback = fallbackRuntime();
|
||||
registerAcpRuntimeBackend({ id: "acpx", runtime: fallback });
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config: { ...config, allowFallback: true },
|
||||
client: fakeClient({
|
||||
launchSession: vi.fn(async () => {
|
||||
throw new Error("launch failed");
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
const events = await collect(
|
||||
runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" }),
|
||||
);
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "text_delta", text: "direct fallback\n" }),
|
||||
expect.objectContaining({ type: "done", stopReason: "complete" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed when Coven launch fails after detection and fallback is disabled", async () => {
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config,
|
||||
client: fakeClient({
|
||||
launchSession: vi.fn(async () => {
|
||||
throw new Error("\u001b]0;spoof\u0007launch\r\nfailed");
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:test",
|
||||
agent: "codex",
|
||||
mode: "oneshot",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
await expect(
|
||||
collect(runtime.runTurn({ handle, text: "Fix tests", mode: "prompt", requestId: "req-1" })),
|
||||
).rejects.toThrow(/Error: launch failed/);
|
||||
});
|
||||
|
||||
it("sanitizes Coven doctor error details", async () => {
|
||||
const runtime = new CovenAcpRuntime({
|
||||
config,
|
||||
client: fakeClient({
|
||||
health: vi.fn(async () => {
|
||||
throw new Error("\u001b[31moffline\r\nnow");
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(runtime.doctor()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
details: ["Error: offline now"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,676 +0,0 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
AcpRuntimeError,
|
||||
getAcpRuntimeBackend,
|
||||
type AcpRuntime,
|
||||
type AcpRuntimeDoctorReport,
|
||||
type AcpRuntimeEvent,
|
||||
type AcpRuntimeHandle,
|
||||
type AcpRuntimeStatus,
|
||||
type AcpRuntimeTurnInput,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
createCovenClient,
|
||||
type CovenClient,
|
||||
type CovenEventRecord,
|
||||
type CovenSessionRecord,
|
||||
} from "./client.js";
|
||||
import type { ResolvedCovenPluginConfig } from "./config.js";
|
||||
import { pathIsInside, realpathIfExists } from "./path-utils.js";
|
||||
|
||||
export const COVEN_BACKEND_ID = "coven";
|
||||
|
||||
const DEFAULT_HARNESSES: Record<string, string> = {
|
||||
codex: "codex",
|
||||
"openai-codex": "codex",
|
||||
"codex-cli": "codex",
|
||||
claude: "claude",
|
||||
"claude-cli": "claude",
|
||||
gemini: "gemini",
|
||||
"google-gemini-cli": "gemini",
|
||||
opencode: "opencode",
|
||||
};
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 5_000;
|
||||
const MAX_COVEN_PROMPT_BYTES = 500_000;
|
||||
const MIN_POLL_INTERVAL_MS = 25;
|
||||
const MAX_POLL_INTERVAL_MS = 10_000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
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;
|
||||
const MAX_SESSION_ID_CHARS = 128;
|
||||
const MAX_EVENT_ID_CHARS = 256;
|
||||
const SAFE_SESSION_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
|
||||
|
||||
type CovenRuntimeSessionState = {
|
||||
agent: string;
|
||||
mode: string;
|
||||
sessionMode?: string;
|
||||
};
|
||||
|
||||
type CovenAcpRuntimeParams = {
|
||||
config: ResolvedCovenPluginConfig;
|
||||
logger?: PluginLogger;
|
||||
client?: CovenClient;
|
||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeAgentId(value: string | undefined): string {
|
||||
return value?.trim().toLowerCase() || "codex";
|
||||
}
|
||||
|
||||
function encodeRuntimeSessionName(state: CovenRuntimeSessionState): string {
|
||||
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 {
|
||||
const prefix = "coven:";
|
||||
if (!value.startsWith(prefix) || value.length > prefix.length + MAX_RUNTIME_SESSION_NAME_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const encoded = value.slice(prefix.length);
|
||||
if (!encoded) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decoded = Buffer.from(encoded, "base64url");
|
||||
if (decoded.byteLength > MAX_RUNTIME_SESSION_NAME_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const jsonText = decoded.toString("utf8");
|
||||
if (Buffer.byteLength(jsonText, "utf8") > MAX_RUNTIME_SESSION_NAME_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(jsonText) as Partial<CovenRuntimeSessionState>;
|
||||
const agent = normalizeAgentId(typeof parsed.agent === "string" ? parsed.agent : undefined);
|
||||
return {
|
||||
agent,
|
||||
mode: typeof parsed.mode === "string" ? parsed.mode : "prompt",
|
||||
...(typeof parsed.sessionMode === "string" ? { sessionMode: parsed.sessionMode } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason ?? new Error("sleep aborted"));
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(signal.reason ?? new Error("sleep aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function titleFromPrompt(prompt: string): string {
|
||||
const compact = sanitizeStatusText(prompt);
|
||||
return compact.slice(0, 80) || "OpenClaw task";
|
||||
}
|
||||
|
||||
function parsePayload(event: CovenEventRecord): Record<string, unknown> {
|
||||
if (Buffer.byteLength(event.payloadJson, "utf8") > MAX_EVENT_PAYLOAD_BYTES) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(event.payloadJson) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
const BEL = String.fromCharCode(0x07);
|
||||
const c0Start = String.fromCharCode(0x00);
|
||||
const c0Backspace = String.fromCharCode(0x08);
|
||||
const c0VerticalTab = String.fromCharCode(0x0b);
|
||||
const c0UnitSeparator = String.fromCharCode(0x1f);
|
||||
const del = String.fromCharCode(0x7f);
|
||||
const c1Start = String.fromCharCode(0x80);
|
||||
const c1End = String.fromCharCode(0x9f);
|
||||
const BIDI_CONTROL_REGEX = /\p{Bidi_Control}/gu;
|
||||
const ANSI_ESCAPE_REGEX = new RegExp(
|
||||
`${ESC}(?:\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)|P[\\s\\S]*?${ESC}\\\\|\\[[\\x20-\\x3f]*[\\x40-\\x7e]|[\\x20-\\x2f]*[\\x30-\\x7e])`,
|
||||
"g",
|
||||
);
|
||||
const TEXT_CONTROL_REGEX = new RegExp(
|
||||
`[${c0Start}-${c0Backspace}${c0VerticalTab}-${c0UnitSeparator}${del}${c1Start}-${c1End}]`,
|
||||
"g",
|
||||
);
|
||||
|
||||
function sanitizeTerminalText(input: string): string {
|
||||
return input
|
||||
.replace(ANSI_ESCAPE_REGEX, "")
|
||||
.replace(TEXT_CONTROL_REGEX, "")
|
||||
.replace(BIDI_CONTROL_REGEX, "");
|
||||
}
|
||||
|
||||
function sanitizeStatusText(input: string): string {
|
||||
return sanitizeTerminalText(input).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function sanitizeStatusField(input: string, fallback = "unknown"): string {
|
||||
return sanitizeStatusText(input).slice(0, MAX_STATUS_FIELD_CHARS) || fallback;
|
||||
}
|
||||
|
||||
function sanitizeErrorText(error: unknown): string {
|
||||
const raw = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
return sanitizeStatusField(raw, "unknown error");
|
||||
}
|
||||
|
||||
function requireSafeSessionId(input: string): string {
|
||||
const value = input.trim();
|
||||
if (!value || value.length > MAX_SESSION_ID_CHARS || !SAFE_SESSION_ID_REGEX.test(value)) {
|
||||
throw new Error("Coven session id is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireSafeEventId(input: string): string {
|
||||
const value = input.trim();
|
||||
if (!value || value.length > MAX_EVENT_ID_CHARS || !SAFE_SESSION_ID_REGEX.test(value)) {
|
||||
throw new Error("Coven event id is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedCovenPrompt(input: string): string {
|
||||
if (Buffer.byteLength(input, "utf8") > MAX_COVEN_PROMPT_BYTES) {
|
||||
throw new Error("Coven prompt exceeded size limit");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function normalizePollIntervalMs(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
return Math.min(MAX_POLL_INTERVAL_MS, Math.max(MIN_POLL_INTERVAL_MS, value));
|
||||
}
|
||||
|
||||
function normalizeStopReason(value: unknown): string {
|
||||
const normalized =
|
||||
typeof value === "string" ? sanitizeStatusText(value).toLowerCase() : "completed";
|
||||
if (normalized === "completed" || normalized === "complete" || normalized === "success") {
|
||||
return "completed";
|
||||
}
|
||||
if (normalized === "killed" || normalized === "cancelled" || normalized === "canceled") {
|
||||
return "cancelled";
|
||||
}
|
||||
if (normalized === "failed" || normalized === "failure" || normalized === "error") {
|
||||
return "error";
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function eventToRuntimeEvents(event: CovenEventRecord): AcpRuntimeEvent[] {
|
||||
const payload = parsePayload(event);
|
||||
if (event.kind === "output") {
|
||||
const text = typeof payload.data === "string" ? sanitizeTerminalText(payload.data) : "";
|
||||
return text ? [{ type: "text_delta", text, stream: "output", tag: "agent_message_chunk" }] : [];
|
||||
}
|
||||
if (event.kind === "exit") {
|
||||
const status = sanitizeStatusField(
|
||||
typeof payload.status === "string" ? payload.status : "completed",
|
||||
"completed",
|
||||
);
|
||||
const exitCode = typeof payload.exitCode === "number" ? payload.exitCode : null;
|
||||
return [
|
||||
{
|
||||
type: "status",
|
||||
text: `coven session ${status}${exitCode == null ? "" : ` exitCode=${exitCode}`}`,
|
||||
tag: "session_info_update",
|
||||
},
|
||||
{ type: "done", stopReason: normalizeStopReason(status) },
|
||||
];
|
||||
}
|
||||
if (event.kind === "kill") {
|
||||
return [
|
||||
{ type: "status", text: "coven session killed", tag: "session_info_update" },
|
||||
{ type: "done", stopReason: "cancelled" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function sessionIsTerminal(session: CovenSessionRecord): boolean {
|
||||
return session.status !== "running" && session.status !== "created";
|
||||
}
|
||||
|
||||
function terminalStatusEvent(session: CovenSessionRecord): AcpRuntimeEvent {
|
||||
const status = sanitizeStatusField(session.status, "completed");
|
||||
const exitCode = typeof session.exitCode === "number" ? session.exitCode : null;
|
||||
return {
|
||||
type: "status",
|
||||
text: `coven session ${status}${exitCode == null ? "" : ` exitCode=${exitCode}`}`,
|
||||
tag: "session_info_update",
|
||||
};
|
||||
}
|
||||
|
||||
export class CovenAcpRuntime implements AcpRuntime {
|
||||
private readonly config: ResolvedCovenPluginConfig;
|
||||
private readonly client: CovenClient;
|
||||
private readonly logger?: PluginLogger;
|
||||
private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
private readonly activeSessionIdsBySessionKey = new Map<string, string>();
|
||||
|
||||
constructor(params: CovenAcpRuntimeParams) {
|
||||
this.config = {
|
||||
...params.config,
|
||||
pollIntervalMs: normalizePollIntervalMs(params.config.pollIntervalMs),
|
||||
};
|
||||
this.logger = params.logger;
|
||||
this.client =
|
||||
params.client ??
|
||||
createCovenClient(params.config.socketPath, { socketRoot: params.config.covenHome });
|
||||
this.sleep = params.sleep ?? defaultSleep;
|
||||
}
|
||||
|
||||
async ensureSession(
|
||||
input: Parameters<AcpRuntime["ensureSession"]>[0],
|
||||
): Promise<AcpRuntimeHandle> {
|
||||
const agent = normalizeAgentId(input.agent);
|
||||
this.resolveHarness(agent);
|
||||
if (!(await this.isCovenAvailable())) {
|
||||
if (!this.config.allowFallback) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
"Coven is unavailable and fallback is disabled.",
|
||||
);
|
||||
}
|
||||
return await this.ensureFallbackSession(input);
|
||||
}
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: COVEN_BACKEND_ID,
|
||||
runtimeSessionName: encodeRuntimeSessionName({
|
||||
agent,
|
||||
mode: "prompt",
|
||||
sessionMode: input.mode,
|
||||
}),
|
||||
...(input.cwd ? { cwd: input.cwd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
|
||||
if (input.handle.backend !== COVEN_BACKEND_ID) {
|
||||
yield* this.runFallbackTurn(input, input.handle);
|
||||
return;
|
||||
}
|
||||
const state = decodeRuntimeSessionName(input.handle.runtimeSessionName);
|
||||
if (!state) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
"Coven runtime session metadata is missing.",
|
||||
);
|
||||
}
|
||||
|
||||
const cwd = this.resolveWorkspaceCwd(input.handle.cwd);
|
||||
const harness = this.resolveHarness(state.agent);
|
||||
let session: CovenSessionRecord | undefined;
|
||||
let sessionId: string;
|
||||
try {
|
||||
const prompt = boundedCovenPrompt(input.text);
|
||||
session = await this.client.launchSession(
|
||||
{
|
||||
projectRoot: this.config.workspaceDir,
|
||||
cwd,
|
||||
harness,
|
||||
prompt,
|
||||
title: titleFromPrompt(prompt),
|
||||
},
|
||||
input.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
const safeError = sanitizeErrorText(error);
|
||||
if (!this.config.allowFallback) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_TURN_FAILED",
|
||||
`Coven launch failed and fallback is disabled: ${safeError}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
this.logger?.warn(
|
||||
`coven launch failed; falling back to ${this.config.fallbackBackend}: ${safeError}`,
|
||||
);
|
||||
yield* this.runFallbackFromCovenHandle(input, state);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!session) {
|
||||
throw new Error("Coven launch did not return a session");
|
||||
}
|
||||
sessionId = requireSafeSessionId(session.id);
|
||||
} catch (error) {
|
||||
await this.killLaunchedSessionBestEffort(session?.id);
|
||||
const safeError = sanitizeErrorText(error);
|
||||
if (!this.config.allowFallback) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_TURN_FAILED",
|
||||
`Coven launch failed and fallback is disabled: ${safeError}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
this.logger?.warn(
|
||||
`coven launch failed; falling back to ${this.config.fallbackBackend}: ${safeError}`,
|
||||
);
|
||||
yield* this.runFallbackFromCovenHandle(input, state);
|
||||
return;
|
||||
}
|
||||
|
||||
input.handle.backendSessionId = sessionId;
|
||||
input.handle.agentSessionId = sessionId;
|
||||
this.activeSessionIdsBySessionKey.set(input.handle.sessionKey, sessionId);
|
||||
yield {
|
||||
type: "status",
|
||||
text: `coven session ${sessionId} started (${sanitizeStatusField(session.harness)})`,
|
||||
tag: "session_info_update",
|
||||
};
|
||||
|
||||
const seenEventIds = new Set<string>();
|
||||
const seenEventQueue: string[] = [];
|
||||
let lastSeenEventId: string | undefined;
|
||||
while (true) {
|
||||
if (input.signal?.aborted) {
|
||||
await this.killActiveSession(sessionId).catch(() => undefined);
|
||||
throw input.signal.reason ?? new Error("Coven turn aborted");
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await this.client.listEvents(
|
||||
sessionId,
|
||||
lastSeenEventId ? { afterEventId: lastSeenEventId } : undefined,
|
||||
input.signal,
|
||||
);
|
||||
if (events.length > MAX_EVENTS_PER_POLL) {
|
||||
throw new Error("Coven daemon returned too many events");
|
||||
}
|
||||
for (const event of events) {
|
||||
const eventId = requireSafeEventId(event.id);
|
||||
if (seenEventIds.has(eventId)) {
|
||||
continue;
|
||||
}
|
||||
seenEventIds.add(eventId);
|
||||
seenEventQueue.push(eventId);
|
||||
while (seenEventQueue.length > MAX_TRACKED_EVENT_IDS) {
|
||||
const removed = seenEventQueue.shift();
|
||||
if (removed) {
|
||||
seenEventIds.delete(removed);
|
||||
}
|
||||
}
|
||||
lastSeenEventId = eventId;
|
||||
for (const runtimeEvent of eventToRuntimeEvents(event)) {
|
||||
yield runtimeEvent;
|
||||
if (runtimeEvent.type === "done") {
|
||||
this.activeSessionIdsBySessionKey.delete(input.handle.sessionKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const latest = await this.client.getSession(sessionId, input.signal);
|
||||
if (sessionIsTerminal(latest)) {
|
||||
yield terminalStatusEvent(latest);
|
||||
yield { type: "done", stopReason: normalizeStopReason(latest.status) };
|
||||
this.activeSessionIdsBySessionKey.delete(input.handle.sessionKey);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted) {
|
||||
await this.killActiveSession(sessionId).catch(() => undefined);
|
||||
throw input.signal.reason ?? error;
|
||||
}
|
||||
this.logger?.warn(`coven polling failed: ${sanitizeErrorText(error)}`);
|
||||
await this.killActiveSession(sessionId).catch(() => undefined);
|
||||
this.activeSessionIdsBySessionKey.delete(input.handle.sessionKey);
|
||||
yield { type: "status", text: "coven session polling failed", tag: "session_info_update" };
|
||||
yield { type: "done", stopReason: "error" };
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sleep(this.config.pollIntervalMs, input.signal);
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return { controls: ["session/status" as const] };
|
||||
}
|
||||
|
||||
async getStatus(
|
||||
input: Parameters<NonNullable<AcpRuntime["getStatus"]>>[0],
|
||||
): Promise<AcpRuntimeStatus> {
|
||||
if (input.handle.backend !== COVEN_BACKEND_ID) {
|
||||
const fallback = this.requireFallbackRuntime(input.handle.backend);
|
||||
return fallback.getStatus
|
||||
? await fallback.getStatus(input)
|
||||
: { summary: `fallback backend ${input.handle.backend} active` };
|
||||
}
|
||||
const sessionId = this.getTrackedSessionId(input.handle);
|
||||
if (!sessionId) {
|
||||
return { summary: "coven runtime ready" };
|
||||
}
|
||||
const session = await this.client.getSession(sessionId, input.signal);
|
||||
const status = sanitizeStatusField(session.status, "completed");
|
||||
const harness = sanitizeStatusField(session.harness);
|
||||
const title = sanitizeStatusField(session.title, "untitled");
|
||||
return {
|
||||
summary: `${status} ${harness} ${title}`,
|
||||
backendSessionId: sessionId,
|
||||
agentSessionId: sessionId,
|
||||
details: {
|
||||
projectRoot: sanitizeStatusField(session.projectRoot),
|
||||
harness,
|
||||
status,
|
||||
exitCode: session.exitCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
try {
|
||||
const health = await this.client.health();
|
||||
return health.ok
|
||||
? { ok: true, message: "Coven daemon is reachable." }
|
||||
: { ok: false, code: "COVEN_UNHEALTHY", message: "Coven daemon did not report healthy." };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "COVEN_UNAVAILABLE",
|
||||
message: "Coven daemon is not reachable; direct ACP fallback remains available.",
|
||||
details: [sanitizeErrorText(error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(input: Parameters<AcpRuntime["cancel"]>[0]): Promise<void> {
|
||||
if (input.handle.backend !== COVEN_BACKEND_ID) {
|
||||
await this.requireFallbackRuntime(input.handle.backend).cancel(input);
|
||||
return;
|
||||
}
|
||||
const sessionId = this.getTrackedSessionId(input.handle);
|
||||
if (sessionId) {
|
||||
await this.killActiveSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async close(input: Parameters<AcpRuntime["close"]>[0]): Promise<void> {
|
||||
if (input.handle.backend !== COVEN_BACKEND_ID) {
|
||||
await this.requireFallbackRuntime(input.handle.backend).close(input);
|
||||
return;
|
||||
}
|
||||
const sessionId = this.getTrackedSessionId(input.handle);
|
||||
if (sessionId && input.reason !== "oneshot-complete") {
|
||||
await this.killActiveSession(sessionId).catch(() => undefined);
|
||||
}
|
||||
this.activeSessionIdsBySessionKey.delete(input.handle.sessionKey);
|
||||
}
|
||||
|
||||
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
|
||||
this.activeSessionIdsBySessionKey.delete(input.sessionKey);
|
||||
const fallback = this.getFallbackRuntime();
|
||||
await fallback?.prepareFreshSession?.(input);
|
||||
}
|
||||
|
||||
private async isCovenAvailable(): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new Error("Coven health check timed out")),
|
||||
HEALTH_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const health = await this.client.health(controller.signal);
|
||||
return health.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveHarness(agent: string): string {
|
||||
const normalized = normalizeAgentId(agent);
|
||||
const harness = this.config.harnesses[normalized] ?? DEFAULT_HARNESSES[normalized];
|
||||
if (!harness) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_INVALID_RUNTIME_OPTION",
|
||||
`Unknown or unauthorized ACP agent "${normalized}" for Coven backend.`,
|
||||
);
|
||||
}
|
||||
return harness;
|
||||
}
|
||||
|
||||
private getFallbackRuntime(backendId = this.config.fallbackBackend): AcpRuntime | null {
|
||||
const normalized = backendId.trim().toLowerCase();
|
||||
if (!normalized || normalized === COVEN_BACKEND_ID) {
|
||||
return null;
|
||||
}
|
||||
return getAcpRuntimeBackend(normalized)?.runtime ?? null;
|
||||
}
|
||||
|
||||
private requireFallbackRuntime(backendId = this.config.fallbackBackend): AcpRuntime {
|
||||
const runtime = this.getFallbackRuntime(backendId);
|
||||
if (!runtime) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
`Coven fallback ACP backend "${backendId}" is not registered.`,
|
||||
);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private async ensureFallbackSession(
|
||||
input: Parameters<AcpRuntime["ensureSession"]>[0],
|
||||
): Promise<AcpRuntimeHandle> {
|
||||
return await this.requireFallbackRuntime().ensureSession(input);
|
||||
}
|
||||
|
||||
private async *runFallbackTurn(
|
||||
input: AcpRuntimeTurnInput,
|
||||
handle: AcpRuntimeHandle,
|
||||
): AsyncIterable<AcpRuntimeEvent> {
|
||||
yield* this.requireFallbackRuntime(handle.backend).runTurn({ ...input, handle });
|
||||
}
|
||||
|
||||
private async *runFallbackFromCovenHandle(
|
||||
input: AcpRuntimeTurnInput,
|
||||
state: CovenRuntimeSessionState,
|
||||
): AsyncIterable<AcpRuntimeEvent> {
|
||||
const fallback = this.requireFallbackRuntime();
|
||||
const handle = await fallback.ensureSession({
|
||||
sessionKey: input.handle.sessionKey,
|
||||
agent: state.agent,
|
||||
mode: state.sessionMode === "persistent" ? "persistent" : "oneshot",
|
||||
cwd: this.resolveWorkspaceCwd(input.handle.cwd),
|
||||
});
|
||||
Object.assign(input.handle, handle);
|
||||
yield* fallback.runTurn({ ...input, handle });
|
||||
}
|
||||
|
||||
private resolveWorkspaceCwd(candidate: string | undefined): string {
|
||||
const cwd = path.resolve(candidate ?? this.config.workspaceDir);
|
||||
const workspaceReal = realpathIfExists(this.config.workspaceDir);
|
||||
const cwdReal = realpathIfExists(cwd);
|
||||
if (!workspaceReal || !cwdReal || !pathIsInside(workspaceReal, cwdReal)) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Coven cwd is outside workspace.");
|
||||
}
|
||||
try {
|
||||
if (!fs.statSync(cwdReal).isDirectory()) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Coven cwd must be a directory.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AcpRuntimeError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Coven cwd must be a directory.");
|
||||
}
|
||||
return cwdReal;
|
||||
}
|
||||
|
||||
private getTrackedSessionId(handle: AcpRuntimeHandle): string | undefined {
|
||||
const tracked = this.activeSessionIdsBySessionKey.get(handle.sessionKey);
|
||||
if (!tracked) {
|
||||
return undefined;
|
||||
}
|
||||
if (handle.backendSessionId && handle.backendSessionId !== tracked) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_INVALID_RUNTIME_OPTION",
|
||||
"Coven session handle does not match this runtime session.",
|
||||
);
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
private async killActiveSession(sessionId: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.client.killSession(sessionId, signal);
|
||||
}
|
||||
|
||||
private async killLaunchedSessionBestEffort(sessionId: string | undefined): Promise<void> {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
await this.client.killSession(sessionId, undefined).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
decodeRuntimeSessionName,
|
||||
encodeRuntimeSessionName,
|
||||
eventToRuntimeEvents,
|
||||
normalizeStopReason,
|
||||
sanitizeErrorText,
|
||||
sanitizeStatusField,
|
||||
sanitizeTerminalText,
|
||||
terminalStatusEvent,
|
||||
titleFromPrompt,
|
||||
};
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
|
@ -419,15 +419,6 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/coven:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/deepgram:
|
||||
dependencies:
|
||||
ws:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue