Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# Raft (OpenClaw plugin)
Raft CLI wake bridge channel plugin for OpenClaw.
Docs: https://docs.openclaw.ai/channels/raft

View File

@@ -0,0 +1,2 @@
// Raft API module exposes the channel plugin contract.
export { raftPlugin } from "./src/channel.js";

13
extensions/raft/index.ts Normal file
View File

@@ -0,0 +1,13 @@
// Raft plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "raft",
name: "Raft",
description: "Raft CLI wake bridge channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "raftPlugin",
},
});

32
extensions/raft/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@openclaw/raft",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/raft",
"version": "2026.6.11",
"dependencies": {
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,45 @@
{
"id": "raft",
"name": "Raft",
"description": "OpenClaw Raft channel plugin for secure CLI wake bridges.",
"activation": {
"onStartup": false
},
"channels": ["raft"],
"channelConfigs": {
"raft": {
"label": "Raft",
"description": "Raft External Agent CLI wake bridge.",
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"enabled": { "type": "boolean" },
"profile": { "type": "string", "minLength": 1 },
"defaultAccount": { "type": "string" },
"accounts": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"enabled": { "type": "boolean" },
"profile": { "type": "string", "minLength": 1 }
}
}
}
}
}
}
},
"channelEnvVars": {
"raft": ["RAFT_PROFILE"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,55 @@
{
"name": "@openclaw/raft",
"version": "2026.6.11",
"description": "OpenClaw Raft channel plugin for Raft CLI wake bridges.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"install": {
"npmSpec": "@openclaw/raft",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"channel": {
"id": "raft",
"label": "Raft",
"selectionLabel": "Raft (CLI wake bridge)",
"docsPath": "/channels/raft",
"docsLabel": "raft",
"blurb": "Raft CLI wake bridge for human and agent collaboration.",
"order": 72
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,10 @@
// Raft plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "raftSetupPlugin",
},
});

View File

@@ -0,0 +1,2 @@
// Keep setup imports narrow so setup does not load the Raft bridge runtime.
export { raftSetupPlugin } from "./src/setup.js";

View File

@@ -0,0 +1,90 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, describe, expect, it } from "vitest";
import { listRaftAccountIds, resolveRaftAccount } from "./accounts.js";
import { RaftConfigSchema } from "./config-schema.js";
import { raftSetupPlugin } from "./setup.js";
const originalProfile = process.env.RAFT_PROFILE;
afterEach(() => {
if (originalProfile === undefined) {
delete process.env.RAFT_PROFILE;
} else {
process.env.RAFT_PROFILE = originalProfile;
}
});
describe("Raft account resolution", () => {
it("uses RAFT_PROFILE only for the default account", () => {
process.env.RAFT_PROFILE = "environment-profile";
const cfg = {
channels: {
raft: {
accounts: {
support: {
profile: "support-profile",
},
},
},
},
} as OpenClawConfig;
expect(listRaftAccountIds(cfg)).toEqual(["default", "support"]);
expect(resolveRaftAccount({ cfg })).toMatchObject({
accountId: "default",
configured: true,
profile: "environment-profile",
});
expect(resolveRaftAccount({ cfg, accountId: "support" })).toMatchObject({
configured: true,
profile: "support-profile",
});
});
it("prefers an explicit profile over RAFT_PROFILE", () => {
process.env.RAFT_PROFILE = "environment-profile";
const cfg = {
channels: {
raft: {
profile: "configured-profile",
},
},
} as OpenClawConfig;
expect(resolveRaftAccount({ cfg }).profile).toBe("configured-profile");
});
it("keeps named account setup scoped to that account", () => {
const next = raftSetupPlugin.setup!.applyAccountConfig({
cfg: {} as OpenClawConfig,
accountId: "support",
input: {
profile: "support-profile",
},
});
expect(next.channels?.raft).toEqual({
enabled: true,
accounts: {
support: {
enabled: true,
profile: "support-profile",
},
},
});
});
it("accepts the supported single and multi-account fields only", () => {
expect(RaftConfigSchema.safeParse({ profile: "default" }).success).toBe(true);
expect(
RaftConfigSchema.safeParse({
accounts: {
support: {
profile: "support",
},
},
}).success,
).toBe(true);
expect(RaftConfigSchema.safeParse({ bridgePort: 3000 }).success).toBe(false);
});
});

View File

@@ -0,0 +1,70 @@
// Raft account resolution keeps CLI profiles scoped to their channel account.
import {
createAccountListHelpers,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const RAFT_CHANNEL_ID = "raft" as const;
type RaftAccountConfig = {
name?: string;
enabled?: boolean;
profile?: string;
accounts?: Record<string, RaftAccountConfig>;
defaultAccount?: string;
};
export type ResolvedRaftAccount = {
accountId: string;
name: string | undefined;
enabled: boolean;
configured: boolean;
profile: string | null;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers(RAFT_CHANNEL_ID, {
normalizeAccountId,
implicitDefaultAccount: {
channelKeys: ["profile"],
envVars: ["RAFT_PROFILE"],
},
});
export const listRaftAccountIds = listAccountIds;
export const resolveDefaultRaftAccountId = resolveDefaultAccountId;
function resolveRaftConfig(cfg: OpenClawConfig): RaftAccountConfig | undefined {
return cfg.channels?.[RAFT_CHANNEL_ID] as RaftAccountConfig | undefined;
}
export function resolveRaftAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedRaftAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultRaftAccountId(params.cfg),
);
const channel = resolveRaftConfig(params.cfg);
const merged = resolveMergedAccountConfig<RaftAccountConfig>({
channelConfig: channel,
accounts: channel?.accounts,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
const configuredProfile = normalizeOptionalString(merged.profile);
const envProfile =
accountId === DEFAULT_ACCOUNT_ID ? normalizeOptionalString(process.env.RAFT_PROFILE) : undefined;
const profile = configuredProfile ?? envProfile ?? null;
return {
accountId,
name: normalizeOptionalString(merged.name),
enabled: channel?.enabled !== false && merged.enabled !== false,
configured: Boolean(profile),
profile,
};
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { raftPlugin } from "./channel.js";
describe("Raft channel plugin", () => {
it("declares a wake-only direct channel", () => {
expect(raftPlugin.meta).toMatchObject({
id: "raft",
docsPath: "/channels/raft",
});
expect(raftPlugin.capabilities).toEqual({
chatTypes: ["direct"],
});
expect(raftPlugin.message).toBeUndefined();
expect(raftPlugin.outbound).toBeUndefined();
});
});

View File

@@ -0,0 +1,102 @@
// Raft channel plugin wires the wake bridge into the canonical channel runtime.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
buildBaseChannelStatusSummary,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import {
listRaftAccountIds,
RAFT_CHANNEL_ID,
resolveDefaultRaftAccountId,
resolveRaftAccount,
type ResolvedRaftAccount,
} from "./accounts.js";
import { raftChannelConfigSchema } from "./config-schema.js";
import { startRaftGatewayAccount } from "./gateway.js";
import { raftSetupPlugin } from "./setup.js";
type RaftProbe = {
cliFound: boolean;
};
export const raftPlugin: ChannelPlugin<ResolvedRaftAccount, RaftProbe> = createChatChannelPlugin({
base: {
id: RAFT_CHANNEL_ID,
meta: {
id: RAFT_CHANNEL_ID,
label: "Raft",
selectionLabel: "Raft (CLI wake bridge)",
docsPath: "/channels/raft",
docsLabel: "raft",
blurb: "Raft CLI wake bridge for human and agent collaboration.",
order: 72,
},
capabilities: {
chatTypes: ["direct"],
},
setup: raftSetupPlugin.setup,
setupWizard: raftSetupPlugin.setupWizard,
reload: { configPrefixes: ["channels.raft"] },
configSchema: raftChannelConfigSchema,
config: {
listAccountIds: listRaftAccountIds,
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveRaftAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultRaftAccountId,
isConfigured: (account) => account.configured,
isEnabled: (account) => account.enabled,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
profile: account.profile,
},
}),
},
status: createComputedAccountStatusAdapter<ResolvedRaftAccount, RaftProbe>({
defaultRuntime: createDefaultChannelRuntimeState("default"),
buildChannelSummary: ({ snapshot }) => buildBaseChannelStatusSummary(snapshot),
probeAccount: async () => ({
cliFound: await detectBinary("raft"),
}),
formatCapabilitiesProbe: ({ probe }) => [
{
text: `Raft CLI: ${probe.cliFound ? "found" : "missing"}`,
...(probe.cliFound ? {} : { tone: "error" as const }),
},
],
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
if (!account.configured) {
return [
{
channel: RAFT_CHANNEL_ID,
accountId: account.accountId,
kind: "config",
message: "Raft account is missing a CLI profile",
fix: "Set channels.raft.profile or RAFT_PROFILE.",
},
];
}
return [];
}),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name ?? undefined,
enabled: account.enabled,
configured: account.configured,
extra: {
profile: account.profile,
},
}),
}),
gateway: {
startAccount: async (ctx) => await startRaftGatewayAccount(ctx),
},
},
});

View File

@@ -0,0 +1,19 @@
// Raft channel configuration schema.
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
const RaftAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
profile: z.string().min(1).optional(),
}).strict();
export const RaftConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
profile: z.string().min(1).optional(),
defaultAccount: z.string().optional(),
accounts: z.record(z.string(), RaftAccountSchema).optional(),
}).strict();
export const raftChannelConfigSchema = buildChannelConfigSchema(RaftConfigSchema);

View File

@@ -0,0 +1,489 @@
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedRaftAccount } from "./accounts.js";
import { startRaftGatewayAccount } from "./gateway.js";
class FakeBridge extends EventEmitter {
kill = vi.fn(() => true);
}
const tempDirs = new Set<string>();
function makeTempDir(prefix: string): string {
// openclaw-temp-dir: allow extension tests cannot import root test helpers
const dir = mkdtempSync(path.join(tmpdir(), prefix));
tempDirs.add(dir);
return dir;
}
function createContext(accountId = "default") {
const status = {
accountId,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
};
const run = vi.fn(async (params: {
raw: unknown;
adapter: {
ingest: (raw: unknown) => {
id: string;
timestamp: number;
rawText: string;
textForAgent: string;
textForCommands: string;
};
resolveTurn: (input: {
id: string;
timestamp: number;
rawText: string;
textForAgent: string;
textForCommands: string;
}) => Promise<{
delivery: {
deliver: () => Promise<{ visibleReplySent: false }>;
};
}>;
};
}) => {
const input = params.adapter.ingest(params.raw);
const turn = await params.adapter.resolveTurn(input);
await turn.delivery.deliver();
});
const ctx = {
cfg: {},
accountId,
account: {
accountId,
name: null,
enabled: true,
configured: true,
profile: "openclaw",
},
runtime: {},
abortSignal: new AbortController().signal,
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
getStatus: () => status,
setStatus: (next: typeof status & Record<string, unknown>) => {
Object.assign(status, next);
},
channelRuntime: {
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
sessionKey: `agent:main:raft:${accountId}`,
})),
},
inbound: {
run,
buildContext: vi.fn(() => ({})),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw-agent.sqlite"),
recordInboundSession: vi.fn(),
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
},
},
};
return {
ctx: ctx as unknown as ChannelGatewayContext<ResolvedRaftAccount>,
controller: new AbortController(),
run,
wakeDedupe: createClaimableDedupe({
ttlMs: 0,
memoryMaxSize: 10_000,
}),
};
}
function createPersistentWakeDedupe(stateDir: string) {
return createClaimableDedupe({
ttlMs: 24 * 60 * 60 * 1000,
memoryMaxSize: 1_000,
pluginId: "raft",
namespacePrefix: "raft-wake-dedupe",
stateMaxEntries: 10_000,
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
}
async function waitFor<T>(getValue: () => T | undefined): Promise<T> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const value = getValue();
if (value !== undefined) {
return value;
}
await new Promise<void>((resolve) => {
setTimeout(resolve, 5);
});
}
throw new Error("Timed out waiting for value.");
}
afterEach(() => {
resetPluginStateStoreForTests();
for (const dir of tempDirs) {
rmSync(dir, { force: true, recursive: true });
}
tempDirs.clear();
vi.restoreAllMocks();
});
describe("Raft wake gateway", () => {
it("keeps a disabled account quiescent until shutdown", async () => {
const { ctx, controller, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
Object.defineProperty(ctx, "account", {
value: {
...ctx.account,
enabled: false,
},
});
const spawnBridge = vi.fn(() => new FakeBridge());
let settled = false;
const start = startRaftGatewayAccount(ctx, { spawnBridge, wakeDedupe }).then(() => {
settled = true;
});
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(settled).toBe(false);
expect(spawnBridge).not.toHaveBeenCalled();
controller.abort();
await start;
});
it("accepts authenticated content-free wake hints and dedupes retry delivery ids", async () => {
const { ctx, controller, run, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
Object.defineProperty(ctx, "account", {
value: {
...ctx.account,
profile: "main'; touch /tmp/pwn; echo '",
},
});
const bridge = new FakeBridge();
let endpoint: string | undefined;
let token: string | undefined;
const start = startRaftGatewayAccount(ctx, {
spawnBridge: (params) => {
endpoint = params.endpoint;
token = params.token;
return bridge;
},
wakeDedupe,
});
const wakeEndpoint = await waitFor(() => endpoint);
const bridgeToken = await waitFor(() => token);
await expect(fetch(wakeEndpoint.replace("/wake", "/health"))).resolves.toMatchObject({
status: 200,
});
await expect(fetch(wakeEndpoint, { method: "POST" })).resolves.toMatchObject({ status: 401 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
}),
).resolves.toMatchObject({ status: 400 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ metadata: { text: "not a wake hint" } }),
}),
).resolves.toMatchObject({ status: 400 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-1", timestamp: 1 }),
}),
).resolves.toMatchObject({ status: 202 });
await expect(
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50")),
).resolves.toMatchObject({ status: 401 });
await expect(
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50"), {
headers: {
"x-raft-bridge-token": bridgeToken,
},
}),
).resolves.toMatchObject({
status: 200,
});
await expect(
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50"), {
headers: {
"x-raft-bridge-token": bridgeToken,
},
}).then((response) => response.json()),
).resolves.toEqual({
dropped: 0,
events: [],
schema: "raft-activity-drain.v1",
});
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-1", timestamp: 2 }),
}),
).resolves.toMatchObject({ status: 202 });
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(run).toHaveBeenCalledTimes(1);
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({
metadata: {
sequence: 1,
source: "bridge",
},
}),
}),
).resolves.toMatchObject({ status: 400 });
expect(run).toHaveBeenCalledTimes(1);
const input = run.mock.calls[0]?.[0].adapter.ingest({ kind: "wake" });
expect(input?.textForAgent).toContain(
`raft --profile 'main'"'"'; touch /tmp/pwn; echo '"'"'' message check`,
);
expect(input?.rawText).not.toContain("wake-1");
controller.abort();
await start;
expect(bridge.kill).toHaveBeenCalledWith("SIGTERM");
});
it("returns the Raft bridge runtime session for accepted wakes", async () => {
const { ctx, controller, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
let endpoint: string | undefined;
let token: string | undefined;
const start = startRaftGatewayAccount(ctx, {
spawnBridge: (params) => {
endpoint = params.endpoint;
token = params.token;
return bridge;
},
wakeDedupe,
});
const wakeEndpoint = await waitFor(() => endpoint);
const bridgeToken = await waitFor(() => token);
try {
const response = await fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-runtime-session" }),
});
expect(response).toMatchObject({ status: 202 });
await expect(response.json()).resolves.toMatchObject({
accepted: true,
ok: true,
runtimeSession: expect.any(String),
});
} finally {
controller.abort();
await start;
}
});
it("rejects oversized payloads before queueing a wake", async () => {
const { ctx, controller, run, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
let endpoint: string | undefined;
let token: string | undefined;
const start = startRaftGatewayAccount(ctx, {
spawnBridge: (params) => {
endpoint = params.endpoint;
token = params.token;
return bridge;
},
wakeDedupe,
});
const wakeEndpoint = await waitFor(() => endpoint);
const bridgeToken = await waitFor(() => token);
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"content-type": "application/json",
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ event: "wake", padding: "x".repeat(17 * 1024) }),
}),
).resolves.toMatchObject({ status: 413 });
expect(run).not.toHaveBeenCalled();
controller.abort();
await start;
});
it("keeps a failed delivery eligible for a bridge retry", async () => {
const { ctx, controller, run, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
let endpoint: string | undefined;
let token: string | undefined;
const start = startRaftGatewayAccount(ctx, {
spawnBridge: (params) => {
endpoint = params.endpoint;
token = params.token;
return bridge;
},
wakeDedupe,
});
const wakeEndpoint = await waitFor(() => endpoint);
const bridgeToken = await waitFor(() => token);
try {
run.mockRejectedValueOnce(new Error("inbound runtime unavailable"));
const request = () => ({
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-retry" }),
});
await expect(fetch(wakeEndpoint, request())).resolves.toMatchObject({ status: 500 });
await expect(fetch(wakeEndpoint, request())).resolves.toMatchObject({ status: 202 });
expect(run).toHaveBeenCalledTimes(2);
} finally {
controller.abort();
await start;
}
});
it("persists accepted wake dedupe across restarts without crossing accounts", async () => {
const stateDir = makeTempDir("openclaw-raft-wake-dedupe-");
try {
const first = createContext();
Object.defineProperty(first.ctx, "abortSignal", { value: first.controller.signal });
const firstBridge = new FakeBridge();
let firstEndpoint: string | undefined;
let firstToken: string | undefined;
const firstStart = startRaftGatewayAccount(first.ctx, {
wakeDedupe: createPersistentWakeDedupe(stateDir),
spawnBridge: (params) => {
firstEndpoint = params.endpoint;
firstToken = params.token;
return firstBridge;
},
});
try {
const endpoint = await waitFor(() => firstEndpoint);
const token = await waitFor(() => firstToken);
await expect(
fetch(endpoint, {
method: "POST",
headers: { "x-raft-bridge-token": token },
body: JSON.stringify({ eventId: "wake-persisted" }),
}),
).resolves.toMatchObject({ status: 202 });
expect(first.run).toHaveBeenCalledTimes(1);
} finally {
first.controller.abort();
await firstStart;
}
const replay = createContext();
Object.defineProperty(replay.ctx, "abortSignal", { value: replay.controller.signal });
const replayBridge = new FakeBridge();
let replayEndpoint: string | undefined;
let replayToken: string | undefined;
const replayStart = startRaftGatewayAccount(replay.ctx, {
wakeDedupe: createPersistentWakeDedupe(stateDir),
spawnBridge: (params) => {
replayEndpoint = params.endpoint;
replayToken = params.token;
return replayBridge;
},
});
try {
const endpoint = await waitFor(() => replayEndpoint);
const token = await waitFor(() => replayToken);
await expect(
fetch(endpoint, {
method: "POST",
headers: { "x-raft-bridge-token": token },
body: JSON.stringify({ eventId: "wake-persisted" }),
}),
).resolves.toMatchObject({ status: 202 });
expect(replay.run).not.toHaveBeenCalled();
} finally {
replay.controller.abort();
await replayStart;
}
const otherAccount = createContext("other");
Object.defineProperty(otherAccount.ctx, "abortSignal", {
value: otherAccount.controller.signal,
});
const otherBridge = new FakeBridge();
let otherEndpoint: string | undefined;
let otherToken: string | undefined;
const otherStart = startRaftGatewayAccount(otherAccount.ctx, {
wakeDedupe: createPersistentWakeDedupe(stateDir),
spawnBridge: (params) => {
otherEndpoint = params.endpoint;
otherToken = params.token;
return otherBridge;
},
});
try {
const endpoint = await waitFor(() => otherEndpoint);
const token = await waitFor(() => otherToken);
await expect(
fetch(endpoint, {
method: "POST",
headers: { "x-raft-bridge-token": token },
body: JSON.stringify({ eventId: "wake-persisted" }),
}),
).resolves.toMatchObject({ status: 202 });
expect(otherAccount.run).toHaveBeenCalledTimes(1);
} finally {
otherAccount.controller.abort();
await otherStart;
}
} finally {
resetPluginStateStoreForTests();
}
});
});

View File

@@ -0,0 +1,407 @@
// Raft gateway lifecycle owns the loopback-only wake endpoint and bridge child process.
import { spawn, type ChildProcess } from "node:child_process";
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import type { EventEmitter } from "node:events";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { Socket } from "node:net";
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import { keepHttpServerTaskAlive, waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import { RAFT_CHANNEL_ID, type ResolvedRaftAccount } from "./accounts.js";
import { dispatchRaftWake } from "./inbound.js";
const BRIDGE_HOST = "127.0.0.1";
const ACTIVITY_DRAIN_PATH = "/activity/drain";
const HEALTH_PATH = "/health";
const WAKE_PATH = "/wake";
const WAKE_TOKEN_HEADER = "x-raft-bridge-token";
const RAFT_ACTIVITY_DRAIN_SCHEMA = "raft-activity-drain.v1";
const MAX_WAKE_BODY_BYTES = 16 * 1024;
const WAKE_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
const WAKE_DEDUPE_MEMORY_MAX_SIZE = 1_000;
const WAKE_DEDUPE_STATE_MAX_ENTRIES = 10_000;
const FORBIDDEN_WAKE_CONTENT_KEYS = new Set([
"body",
"content",
"message",
"messages",
"preview",
"snippet",
"text",
]);
const WAKE_EVENT_ID_FIELDS = [
"eventId",
"attemptId",
"messageId",
"delivery_id",
"wake_id",
"id",
] as const;
type RaftBridgeProcess = Pick<ChildProcess, "kill"> & Pick<EventEmitter, "once">;
type RaftGatewayDeps = {
createToken?: () => string;
spawnBridge?: (params: { profile: string; endpoint: string; token: string }) => RaftBridgeProcess;
wakeDedupe?: ClaimableDedupe;
};
class WakeRequestError extends Error {
constructor(
readonly statusCode: number,
message: string,
) {
super(message);
}
}
function createToken(): string {
return randomBytes(32).toString("hex");
}
function spawnRaftBridge(params: {
profile: string;
endpoint: string;
token: string;
}): RaftBridgeProcess {
// Raft owns the fixed bridge command. OpenClaw passes profile/loopback
// endpoint/token as separate argv/env fields; wake payloads never reach argv.
return spawn(
"raft",
[
"--profile",
params.profile,
"agent",
"bridge",
"--wake-adapter",
"wake-channel",
"--wake-channel-endpoint",
params.endpoint,
],
{
env: {
...process.env,
RAFT_CHANNEL_TOKEN: params.token,
},
stdio: "ignore",
windowsHide: true,
},
);
}
function hasMatchingToken(request: IncomingMessage, expected: string): boolean {
const value = request.headers[WAKE_TOKEN_HEADER];
if (typeof value !== "string") {
return false;
}
const received = Buffer.from(value);
const required = Buffer.from(expected);
return received.length === required.length && timingSafeEqual(received, required);
}
async function readWakePayload(request: IncomingMessage): Promise<Record<string, unknown>> {
const chunks: Buffer[] = [];
let bytes = 0;
let tooLarge = false;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.length;
if (bytes > MAX_WAKE_BODY_BYTES) {
tooLarge = true;
continue;
}
chunks.push(buffer);
}
if (tooLarge) {
throw new WakeRequestError(413, "Wake payload exceeds the 16 KiB limit.");
}
const text = Buffer.concat(chunks).toString("utf8").trim();
if (!text) {
return {};
}
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
throw new WakeRequestError(400, "Wake payload must be valid JSON.");
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new WakeRequestError(400, "Wake payload must be an object.");
}
return payload as Record<string, unknown>;
}
function containsMessageContent(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some(containsMessageContent);
}
if (!value || typeof value !== "object") {
return false;
}
return Object.entries(value as Record<string, unknown>).some(
([key, child]) =>
FORBIDDEN_WAKE_CONTENT_KEYS.has(key.toLowerCase()) || containsMessageContent(child),
);
}
function resolveWakeEventId(payload: Record<string, unknown>): string | undefined {
for (const field of WAKE_EVENT_ID_FIELDS) {
const value = payload[field];
if (typeof value === "string" && value) {
return value;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
}
return undefined;
}
function hashWakeEventId(eventId: string): string {
return createHash("sha256").update(eventId).digest("hex");
}
function resolveWakeDedupeKey(payload: Record<string, unknown>): string | undefined {
const eventId = resolveWakeEventId(payload);
return eventId ? hashWakeEventId(`id:${eventId}`) : undefined;
}
function sendJson(response: ServerResponse, statusCode: number, body: Record<string, unknown>) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
response.end(JSON.stringify(body));
}
function closeServer(server: Server, sockets: Set<Socket>) {
for (const socket of sockets) {
socket.destroy();
}
if (server.listening) {
server.close();
}
}
function stopBridge(child: RaftBridgeProcess) {
child.kill("SIGTERM");
const forceKill = setTimeout(() => child.kill("SIGKILL"), 5_000);
forceKill.unref();
child.once("exit", () => clearTimeout(forceKill));
}
async function listenLoopback(server: Server): Promise<number> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, BRIDGE_HOST, () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Raft wake server did not bind a TCP port.");
}
return address.port;
}
export async function startRaftGatewayAccount(
ctx: ChannelGatewayContext<ResolvedRaftAccount>,
deps: RaftGatewayDeps = {},
): Promise<void> {
const profile = ctx.account.profile;
if (!ctx.account.enabled) {
await waitUntilAbort(ctx.abortSignal);
return;
}
if (!profile) {
throw new Error(`Raft account "${ctx.accountId}" is missing a CLI profile.`);
}
if (!ctx.channelRuntime) {
throw new Error("Raft requires OpenClaw channel runtime support. Update OpenClaw and retry.");
}
const wakeQueue = new KeyedAsyncQueue();
const wakeDedupe =
deps.wakeDedupe ??
createClaimableDedupe({
ttlMs: WAKE_DEDUPE_TTL_MS,
memoryMaxSize: WAKE_DEDUPE_MEMORY_MAX_SIZE,
pluginId: RAFT_CHANNEL_ID,
namespacePrefix: "raft-wake-dedupe",
stateMaxEntries: WAKE_DEDUPE_STATE_MAX_ENTRIES,
onDiskError: (error) => {
ctx.log?.warn?.(`Raft wake dedupe storage failed: ${String(error)}`);
},
});
const token = (deps.createToken ?? createToken)();
const runtimeSession = randomUUID();
const sockets = new Set<Socket>();
let stopped = false;
let bridgeExited: Error | undefined;
const server = createServer((request, response) => {
void (async () => {
if (request.method === "GET" && request.url === HEALTH_PATH) {
sendJson(response, 200, { ok: true });
return;
}
if (
request.method === "GET" &&
new URL(request.url ?? "/", `http://${BRIDGE_HOST}`).pathname === ACTIVITY_DRAIN_PATH
) {
if (!hasMatchingToken(request, token)) {
sendJson(response, 401, { error: "unauthorized" });
return;
}
// Raft drains runtime activity after each wake pass. OpenClaw has no
// portable Raft activity events to export, but must acknowledge an
// empty batch so the bridge's current protocol remains healthy.
sendJson(response, 200, {
schema: RAFT_ACTIVITY_DRAIN_SCHEMA,
events: [],
dropped: 0,
});
return;
}
if (request.method !== "POST" || request.url !== WAKE_PATH) {
sendJson(response, 404, { error: "not found" });
return;
}
if (!hasMatchingToken(request, token)) {
sendJson(response, 401, { error: "unauthorized" });
return;
}
const payload = await readWakePayload(request);
if (containsMessageContent(payload)) {
throw new WakeRequestError(400, "Wake payload must not include message content.");
}
// Raft owns wake metadata and its schema evolution. OpenClaw accepts only
// content-free hints, then discards the payload so it cannot reach agent state.
// Hash delivery identities before durable retention because Raft can retry accepted wakes.
ctx.setStatus({
...ctx.getStatus(),
accountId: ctx.accountId,
lastInboundAt: Date.now(),
});
const dedupeKey = resolveWakeDedupeKey(payload);
if (!dedupeKey) {
throw new WakeRequestError(400, "Wake payload must include a stable event identity.");
}
const dispatched = await wakeQueue.enqueue(ctx.accountId, async () => {
if (ctx.abortSignal?.aborted) {
throw new WakeRequestError(503, "Raft Gateway is stopping.");
}
const claim = await wakeDedupe.claim(dedupeKey, { namespace: ctx.accountId });
if (claim.kind === "duplicate") {
return false;
}
if (claim.kind === "inflight") {
if (await claim.pending) {
return false;
}
throw new WakeRequestError(503, "Raft wake delivery is retrying.");
}
try {
await dispatchRaftWake({ ctx });
} catch (error) {
wakeDedupe.release(dedupeKey, { namespace: ctx.accountId, error });
throw error;
}
await wakeDedupe.commit(dedupeKey, { namespace: ctx.accountId });
return true;
});
sendJson(response, 202, {
ok: true,
accepted: true,
runtimeSession,
...(dispatched ? {} : { duplicate: true }),
});
})().catch((error: unknown) => {
const statusCode = error instanceof WakeRequestError ? error.statusCode : 500;
const message = error instanceof WakeRequestError ? error.message : "Internal server error.";
ctx.log?.warn?.(`Raft wake request rejected: ${message}`);
if (!response.headersSent) {
sendJson(response, statusCode, { error: message });
} else {
response.destroy();
}
});
});
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
let bridge: RaftBridgeProcess | undefined;
let bridgeStopRequested = false;
const requestBridgeStop = () => {
if (!bridge || bridgeStopRequested) {
return;
}
bridgeStopRequested = true;
stopBridge(bridge);
};
try {
const port = await listenLoopback(server);
const endpoint = `http://${BRIDGE_HOST}:${port}${WAKE_PATH}`;
bridge = (deps.spawnBridge ?? spawnRaftBridge)({ profile, endpoint, token });
bridge.once("error", (error) => {
if (!stopped) {
bridgeExited = new Error(`Raft bridge failed to start: ${String(error)}`);
closeServer(server, sockets);
}
});
bridge.once("exit", (code, signal) => {
if (!stopped) {
bridgeExited = new Error(
`Raft bridge exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "none"}).`,
);
closeServer(server, sockets);
}
});
ctx.setStatus({
accountId: ctx.accountId,
running: true,
connected: true,
lastStartAt: Date.now(),
lastError: null,
});
ctx.log?.info?.(`Raft bridge started for profile "${profile}".`);
await keepHttpServerTaskAlive({
server,
abortSignal: ctx.abortSignal,
onAbort: () => {
stopped = true;
requestBridgeStop();
closeServer(server, sockets);
},
});
if (bridgeExited) {
throw bridgeExited;
}
} catch (error) {
ctx.setStatus({
accountId: ctx.accountId,
running: false,
connected: false,
lastStopAt: Date.now(),
lastError: String(error),
});
throw error;
} finally {
stopped = true;
requestBridgeStop();
closeServer(server, sockets);
ctx.setStatus({
accountId: ctx.accountId,
running: false,
connected: false,
lastStopAt: Date.now(),
...(bridgeExited ? { lastError: bridgeExited.message } : {}),
});
}
}

View File

@@ -0,0 +1,118 @@
// Raft delivers wake hints only; message content stays in the operator's Raft CLI session.
import { randomUUID } from "node:crypto";
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type { ResolvedRaftAccount } from "./accounts.js";
import { RAFT_CHANNEL_ID } from "./accounts.js";
const WAKE_TEXT =
"Raft wake hint received. Check Raft for pending messages, then reply through the Raft CLI.";
type RaftChannelRuntime = Pick<
PluginRuntime["channel"],
"inbound" | "reply" | "routing" | "session"
>;
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export async function dispatchRaftWake(params: {
ctx: ChannelGatewayContext<ResolvedRaftAccount>;
}): Promise<void> {
const { ctx } = params;
// Gateway supplies the full runtime; the public context type intentionally
// exposes only runtime contexts so external plugins cannot assume more.
const channelRuntime = ctx.channelRuntime as RaftChannelRuntime | undefined;
const profile = ctx.account.profile;
if (!channelRuntime || !profile) {
return;
}
const route = channelRuntime.routing.resolveAgentRoute({
cfg: ctx.cfg,
channel: RAFT_CHANNEL_ID,
accountId: ctx.accountId,
peer: {
kind: "direct",
id: profile,
},
});
const timestamp = Date.now();
const command = `raft --profile ${shellQuote(profile)}`;
await channelRuntime.inbound.run({
channel: RAFT_CHANNEL_ID,
accountId: ctx.accountId,
raw: {
kind: "wake",
profile,
},
adapter: {
ingest: () => ({
id: randomUUID(),
timestamp,
rawText: WAKE_TEXT,
textForAgent: `${WAKE_TEXT}\n\nUse \`${command} message check\` to read pending messages and \`${command} message send\` to respond.`,
textForCommands: "",
}),
resolveTurn: async (input) => {
const ctxPayload = channelRuntime.inbound.buildContext({
channel: RAFT_CHANNEL_ID,
accountId: ctx.accountId,
messageId: input.id,
timestamp: input.timestamp,
from: `raft:${profile}`,
sender: {
id: profile,
name: "Raft",
},
conversation: {
kind: "direct",
id: profile,
label: `Raft ${profile}`,
},
route: {
agentId: route.agentId,
accountId: ctx.accountId,
routeSessionKey: route.sessionKey,
dispatchSessionKey: route.sessionKey,
},
reply: {
to: `raft:${profile}`,
},
message: {
rawBody: input.rawText,
commandBody: input.textForCommands,
bodyForAgent: input.textForAgent,
},
});
const storePath = channelRuntime.session.resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
return {
cfg: ctx.cfg,
channel: RAFT_CHANNEL_ID,
accountId: ctx.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: channelRuntime.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher,
// Raft's bridge only transports wake hints. The agent owns CLI delivery
// after it reads the pending Raft messages, so OpenClaw must not emit a
// duplicate synthetic reply through the channel dispatcher.
delivery: {
deliver: async () => ({ visibleReplySent: false }),
},
record: {
onRecordError: (error) =>
ctx.log?.warn?.(`Raft session metadata update failed: ${String(error)}`),
},
};
},
},
});
}

View File

@@ -0,0 +1,103 @@
// Raft plugin setup owns only the Raft CLI profile, never Raft credentials.
import { createPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createDetectedBinaryStatus,
formatDocsLink,
setSetupChannelEnabled,
} from "openclaw/plugin-sdk/setup";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
listRaftAccountIds,
RAFT_CHANNEL_ID,
resolveDefaultRaftAccountId,
resolveRaftAccount,
type ResolvedRaftAccount,
} from "./accounts.js";
const raftSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: RAFT_CHANNEL_ID,
buildPatch: (input) => {
const profile = normalizeOptionalString(input.profile);
return profile ? { profile } : {};
},
validateInput: ({ cfg, accountId, input }) => {
if (normalizeOptionalString(input.profile) ?? resolveRaftAccount({ cfg, accountId }).profile) {
return null;
}
return "Raft requires a CLI profile.";
},
});
export const raftSetupPlugin: ChannelPlugin<ResolvedRaftAccount> = {
id: RAFT_CHANNEL_ID,
meta: {
id: RAFT_CHANNEL_ID,
label: "Raft",
selectionLabel: "Raft (CLI wake bridge)",
docsPath: "/channels/raft",
docsLabel: "raft",
blurb: "Raft CLI wake bridge for human and agent collaboration.",
order: 72,
},
capabilities: {
chatTypes: ["direct"],
},
setup: raftSetupAdapter,
config: {
listAccountIds: listRaftAccountIds,
resolveAccount: (cfg, accountId) => resolveRaftAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultRaftAccountId,
isConfigured: (account) => account.configured,
isEnabled: (account) => account.enabled,
},
setupWizard: {
channel: RAFT_CHANNEL_ID,
resolveShouldPromptAccountIds: () => false,
status: createDetectedBinaryStatus({
channelLabel: "Raft",
binaryLabel: "raft",
configuredLabel: "configured",
unconfiguredLabel: "needs a CLI profile",
configuredHint: "configured",
unconfiguredHint: "install and sign in to the Raft CLI",
configuredScore: 1,
unconfiguredScore: 4,
resolveConfigured: ({ cfg, accountId }) =>
accountId
? resolveRaftAccount({ cfg, accountId }).configured
: listRaftAccountIds(cfg).some(
(resolvedAccountId) => resolveRaftAccount({ cfg, accountId: resolvedAccountId }).configured,
),
resolveBinaryPath: () => "raft",
detectBinary,
}),
introNote: {
title: "Raft setup",
lines: [
"Create a Raft External Agent and sign in with the Raft CLI on this Gateway host.",
`Docs: ${formatDocsLink("/channels/raft", "channels/raft")}`,
],
},
credentials: [],
textInputs: [
{
inputKey: "profile",
message: "Raft CLI profile",
currentValue: ({ cfg, accountId }) =>
resolveRaftAccount({ cfg, accountId }).profile ?? undefined,
validate: ({ value }) => (normalizeOptionalString(value) ? undefined : "Required"),
normalizeValue: ({ value }) => normalizeOptionalString(value) ?? "",
},
],
completionNote: {
title: "Raft next steps",
lines: [
"Restart the Gateway, then send a Raft message to wake the agent.",
`Docs: ${formatDocsLink("/channels/raft", "channels/raft")}`,
],
},
disable: (cfg) => setSetupChannelEnabled(cfg, RAFT_CHANNEL_ID, false),
},
};

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}