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
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:
5
extensions/raft/README.md
Normal file
5
extensions/raft/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Raft (OpenClaw plugin)
|
||||
|
||||
Raft CLI wake bridge channel plugin for OpenClaw.
|
||||
|
||||
Docs: https://docs.openclaw.ai/channels/raft
|
||||
2
extensions/raft/channel-plugin-api.ts
Normal file
2
extensions/raft/channel-plugin-api.ts
Normal 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
13
extensions/raft/index.ts
Normal 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
32
extensions/raft/npm-shrinkwrap.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
extensions/raft/openclaw.plugin.json
Normal file
45
extensions/raft/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
55
extensions/raft/package.json
Normal file
55
extensions/raft/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
10
extensions/raft/setup-entry.ts
Normal file
10
extensions/raft/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
2
extensions/raft/setup-plugin-api.ts
Normal file
2
extensions/raft/setup-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Keep setup imports narrow so setup does not load the Raft bridge runtime.
|
||||
export { raftSetupPlugin } from "./src/setup.js";
|
||||
90
extensions/raft/src/accounts.test.ts
Normal file
90
extensions/raft/src/accounts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
extensions/raft/src/accounts.ts
Normal file
70
extensions/raft/src/accounts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
16
extensions/raft/src/channel.test.ts
Normal file
16
extensions/raft/src/channel.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
102
extensions/raft/src/channel.ts
Normal file
102
extensions/raft/src/channel.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
});
|
||||
19
extensions/raft/src/config-schema.ts
Normal file
19
extensions/raft/src/config-schema.ts
Normal 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);
|
||||
489
extensions/raft/src/gateway.test.ts
Normal file
489
extensions/raft/src/gateway.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
407
extensions/raft/src/gateway.ts
Normal file
407
extensions/raft/src/gateway.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
118
extensions/raft/src/inbound.ts
Normal file
118
extensions/raft/src/inbound.ts
Normal 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)}`),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
103
extensions/raft/src/setup.ts
Normal file
103
extensions/raft/src/setup.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
16
extensions/raft/tsconfig.json
Normal file
16
extensions/raft/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user