Merge branch 'main' into feat/enable-vscode-debugging

This commit is contained in:
Andrew Porter 2026-03-14 16:09:14 -07:00 committed by GitHub
commit acaca23ec9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 56160 additions and 182 deletions

45
.github/CODEOWNERS vendored
View File

@ -1,6 +1,51 @@
# Protect the ownership rules themselves.
/.github/CODEOWNERS @steipete
# WARNING: GitHub CODEOWNERS uses last-match-wins semantics.
# If you add overlapping rules below the secops block, include @openclaw/secops
# on those entries too or you can silently remove required secops review.
# Security-sensitive code, config, and docs require secops review.
/SECURITY.md @openclaw/secops
/.github/dependabot.yml @openclaw/secops
/.github/codeql/ @openclaw/secops
/.github/workflows/codeql.yml @openclaw/secops
/src/security/ @openclaw/secops
/src/secrets/ @openclaw/secops
/src/config/*secret*.ts @openclaw/secops
/src/config/**/*secret*.ts @openclaw/secops
/src/gateway/*auth*.ts @openclaw/secops
/src/gateway/**/*auth*.ts @openclaw/secops
/src/gateway/*secret*.ts @openclaw/secops
/src/gateway/**/*secret*.ts @openclaw/secops
/src/gateway/security-path*.ts @openclaw/secops
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/secops
/src/gateway/protocol/**/*secret*.ts @openclaw/secops
/src/gateway/server-methods/secrets*.ts @openclaw/secops
/src/agents/*auth*.ts @openclaw/secops
/src/agents/**/*auth*.ts @openclaw/secops
/src/agents/auth-profiles*.ts @openclaw/secops
/src/agents/auth-health*.ts @openclaw/secops
/src/agents/auth-profiles/ @openclaw/secops
/src/agents/sandbox.ts @openclaw/secops
/src/agents/sandbox-*.ts @openclaw/secops
/src/agents/sandbox/ @openclaw/secops
/src/infra/secret-file*.ts @openclaw/secops
/src/cron/stagger.ts @openclaw/secops
/src/cron/service/jobs.ts @openclaw/secops
/docs/security/ @openclaw/secops
/docs/gateway/authentication.md @openclaw/secops
/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @openclaw/secops
/docs/gateway/sandboxing.md @openclaw/secops
/docs/gateway/secrets-plan-contract.md @openclaw/secops
/docs/gateway/secrets.md @openclaw/secops
/docs/gateway/security/ @openclaw/secops
/docs/cli/approvals.md @openclaw/secops
/docs/cli/sandbox.md @openclaw/secops
/docs/cli/security.md @openclaw/secops
/docs/cli/secrets.md @openclaw/secops
/docs/reference/secretref-credential-surface.md @openclaw/secops
/docs/reference/secretref-user-supplied-credentials-matrix.json @openclaw/secops
# Release workflow and its supporting release-path checks.
/.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers
/docs/reference/RELEASING.md @openclaw/openclaw-release-managers

View File

@ -4,6 +4,7 @@ on:
pull_request:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@ -14,6 +15,7 @@ env:
jobs:
no-tabs:
if: github.event_name != 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@ -45,6 +47,7 @@ jobs:
PY
actionlint:
if: github.event_name != 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@ -68,3 +71,19 @@ jobs:
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py
config-docs-drift:
if: github.event_name == 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Check config docs drift statefile
run: pnpm config:docs:check

View File

@ -9,6 +9,7 @@
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Auto-close labels (issues and PRs)

View File

@ -21,6 +21,9 @@ Docs: https://docs.openclaw.ai
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
## 2026.3.13

View File

@ -96,6 +96,7 @@ Welcome to the lobster tank! 🦞
- Reply to or resolve bot review conversations you addressed before asking for review again
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
- Use American English spelling and grammar in code, comments, docs, and UI strings
- Do not edit files covered by `CODEOWNERS` security ownership unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted review surfaces, not opportunistic cleanup targets.
## Review Conversations Are Author-Owned

View File

@ -0,0 +1,8 @@
# Generated Docs Artifacts
These baseline artifacts are generated from the repo-owned OpenClaw config schema and bundled channel/plugin metadata.
- Do not edit `config-baseline.json` by hand.
- Do not edit `config-baseline.jsonl` by hand.
- Regenerate it with `pnpm config:docs:gen`.
- Validate it in CI or locally with `pnpm config:docs:check`.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -289,7 +289,7 @@ Look for:
- Valid browser executable path.
- CDP profile reachability.
- Extension relay tab attachment for `profile="chrome-relay"`.
- Extension relay tab attachment (if an extension relay profile is configured).
Common signatures:

View File

@ -76,6 +76,7 @@ Historical note:
- [ ] `pnpm check`
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`.
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
- If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`

View File

@ -25,7 +25,7 @@ Note, selecting 'chromium-browser' instead of 'chromium'
chromium-browser is already the newest version (2:1snap1-0ubuntu2).
```
This is NOT a real browser it's just a wrapper.
This is NOT a real browser - it's just a wrapper.
### Solution 1: Install Google Chrome (Recommended)
@ -123,7 +123,7 @@ curl -s http://127.0.0.1:18791/tabs
### Problem: "Chrome extension relay is running, but no tab is connected"
Youre using the `chrome-relay` profile (extension relay). It expects the OpenClaw
You're using an extension relay profile. It expects the OpenClaw
browser extension to be attached to a live tab.
Fix options:

View File

@ -62,19 +62,14 @@ After upgrading OpenClaw:
## Use it (set gateway token once)
OpenClaw ships with a built-in browser profile named `chrome-relay` that targets the extension relay on the default port.
To use the extension relay, create a browser profile for it:
Before first attach, open extension Options and set:
- `Port` (default `18792`)
- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`)
Use it:
- CLI: `openclaw browser --browser-profile chrome-relay tabs`
- Agent tool: `browser` with `profile="chrome-relay"`
If you want a different name or a different relay port, create your own profile:
Then create a profile:
```bash
openclaw browser create-profile \
@ -84,6 +79,11 @@ openclaw browser create-profile \
--color "#00AA00"
```
Use it:
- CLI: `openclaw browser --browser-profile my-chrome tabs`
- Agent tool: `browser` with `profile="my-chrome"`
### Custom Gateway ports
If you're using a custom gateway port, the extension relay port is automatically derived:

View File

@ -15,9 +15,12 @@ const {
mockCreateFeishuReplyDispatcher,
mockSendMessageFeishu,
mockGetMessageFeishu,
mockListFeishuThreadMessages,
mockDownloadMessageResourceFeishu,
mockCreateFeishuClient,
mockResolveAgentRoute,
mockReadSessionUpdatedAt,
mockResolveStorePath,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
@ -26,6 +29,7 @@ const {
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
buffer: Buffer.from("video"),
contentType: "video/mp4",
@ -40,6 +44,8 @@ const {
mainSessionKey: "agent:main:main",
matchedBy: "default",
})),
mockReadSessionUpdatedAt: vi.fn(),
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
}));
vi.mock("./reply-dispatcher.js", () => ({
@ -49,6 +55,7 @@ vi.mock("./reply-dispatcher.js", () => ({
vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu,
listFeishuThreadMessages: mockListFeishuThreadMessages,
}));
vi.mock("./media.js", () => ({
@ -140,6 +147,8 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockReadSessionUpdatedAt.mockReturnValue(undefined);
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
@ -166,6 +175,12 @@ describe("handleFeishuMessage command authorization", () => {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
session: {
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(
() => ({}),
@ -1709,6 +1724,123 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("bootstraps topic thread context only for a new thread session", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "ou-topic-user",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup_existing_session",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
storePath: "/tmp/feishu-sessions.json",
sessionKey: "agent:main:feishu:dm:ou-attacker",
});
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
expect.objectContaining({
rootMessageId: "om_topic_root",
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("skips topic thread bootstrap when the thread session already exists", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: undefined,
ThreadHistoryBody: undefined,
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@ -29,7 +29,7 @@ import {
import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
@ -1239,16 +1239,17 @@ export async function handleFeishuMessage(params: {
const mediaPayload = buildAgentMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
const quotedMsg = await getMessageFeishu({
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (quotedMsg) {
quotedContent = quotedMsg.content;
if (quotedMessageInfo) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
@ -1258,6 +1259,11 @@ export async function handleFeishuMessage(params: {
}
}
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender");
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx,
@ -1309,13 +1315,140 @@ export async function handleFeishuMessage(params: {
}))
: undefined;
const threadContextBySessionKey = new Map<
string,
{
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
}
>();
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
let rootMessageFetched = false;
const getRootMessageInfo = async () => {
if (!ctx.rootId) {
return null;
}
if (!rootMessageFetched) {
rootMessageFetched = true;
if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
rootMessageInfo = quotedMessageInfo;
} else {
try {
rootMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.rootId,
accountId: account.accountId,
});
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
rootMessageInfo = null;
}
}
}
return rootMessageInfo ?? null;
};
const resolveThreadContextForAgent = async (agentId: string, agentSessionKey: string) => {
const cached = threadContextBySessionKey.get(agentSessionKey);
if (cached) {
return cached;
}
const threadContext: {
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
} = {
threadLabel:
(ctx.rootId || ctx.threadId) && isTopicSessionForThread
? `Feishu thread in ${ctx.chatId}`
: undefined,
};
if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: agentSessionKey,
});
if (previousThreadSessionTimestamp) {
log(
`feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const rootMsg = await getRootMessageInfo();
let feishuThreadId = ctx.threadId ?? rootMsg?.threadId;
if (feishuThreadId) {
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
}
if (!feishuThreadId) {
log(
`feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
try {
const threadMessages = await listFeishuThreadMessages({
cfg,
threadId: feishuThreadId,
currentMessageId: ctx.messageId,
rootMessageId: ctx.rootId,
limit: 20,
accountId: account.accountId,
});
const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
const relevantMessages = senderScoped
? threadMessages.filter(
(msg) => msg.senderType === "app" || msg.senderId === ctx.senderOpenId,
)
: threadMessages;
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
const historyMessages =
rootMsg?.content || ctx.rootId ? relevantMessages : relevantMessages.slice(1);
const historyParts = historyMessages.map((msg) => {
const role = msg.senderType === "app" ? "assistant" : "user";
return core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: `${msg.senderId ?? "Unknown"} (${role})`,
timestamp: msg.createTime,
body: msg.content,
envelope: envelopeOptions,
});
});
threadContext.threadStarterBody = threadStarterBody;
threadContext.threadHistoryBody =
historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
log(
`feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
);
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
}
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
};
// --- Shared context builder for dispatch ---
const buildCtxPayloadForAgent = (
const buildCtxPayloadForAgent = async (
agentId: string,
agentSessionKey: string,
agentAccountId: string,
wasMentioned: boolean,
) =>
core.channel.reply.finalizeInboundContext({
) => {
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey);
return core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
@ -1335,6 +1468,12 @@ export async function handleFeishuMessage(params: {
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
ThreadStarterBody: threadContext.threadStarterBody,
ThreadHistoryBody: threadContext.threadHistoryBody,
ThreadLabel: threadContext.threadLabel,
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
// ID and would produce invalid reply targets downstream.
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
Timestamp: Date.now(),
WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized,
@ -1343,6 +1482,7 @@ export async function handleFeishuMessage(params: {
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
...mediaPayload,
});
};
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time
@ -1402,7 +1542,8 @@ export async function handleFeishuMessage(params: {
}
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentCtx = buildCtxPayloadForAgent(
const agentCtx = await buildCtxPayloadForAgent(
agentId,
agentSessionKey,
route.accountId,
ctx.mentionedBot && agentId === activeAgentId,
@ -1502,7 +1643,8 @@ export async function handleFeishuMessage(params: {
);
} else {
// --- Single-agent dispatch (existing behavior) ---
const ctxPayload = buildCtxPayloadForAgent(
const ctxPayload = await buildCtxPayloadForAgent(
route.agentId,
route.sessionKey,
route.accountId,
ctx.mentionedBot,

View File

@ -1,12 +1,14 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMessageFeishu } from "./send.js";
import { getMessageFeishu, listFeishuThreadMessages } from "./send.js";
const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockClientList: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
@ -27,6 +29,7 @@ describe("getMessageFeishu", () => {
im: {
message: {
get: mockClientGet,
list: mockClientList,
},
},
});
@ -165,4 +168,68 @@ describe("getMessageFeishu", () => {
}),
);
});
it("reuses the same content parsing for thread history messages", async () => {
mockClientList.mockResolvedValueOnce({
code: 0,
data: {
items: [
{
message_id: "om_root",
msg_type: "text",
body: {
content: JSON.stringify({ text: "root starter" }),
},
},
{
message_id: "om_card",
msg_type: "interactive",
body: {
content: JSON.stringify({
body: {
elements: [{ tag: "markdown", content: "hello from card 2.0" }],
},
}),
},
sender: {
id: "app_1",
sender_type: "app",
},
create_time: "1710000000000",
},
{
message_id: "om_file",
msg_type: "file",
body: {
content: JSON.stringify({ file_key: "file_v3_123" }),
},
sender: {
id: "ou_1",
sender_type: "user",
},
create_time: "1710000001000",
},
],
},
});
const result = await listFeishuThreadMessages({
cfg: {} as ClawdbotConfig,
threadId: "omt_1",
rootMessageId: "om_root",
});
expect(result).toEqual([
expect.objectContaining({
messageId: "om_file",
contentType: "file",
content: "[file message]",
}),
expect.objectContaining({
messageId: "om_card",
contentType: "interactive",
content: "hello from card 2.0",
}),
]);
});
});

View File

@ -65,6 +65,7 @@ type FeishuMessageGetItem = {
message_id?: string;
chat_id?: string;
chat_type?: FeishuChatType;
thread_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: FeishuMessageSender;
@ -151,13 +152,19 @@ function parseInteractiveCardContent(parsed: unknown): string {
return "[Interactive Card]";
}
const candidate = parsed as { elements?: unknown };
if (!Array.isArray(candidate.elements)) {
// Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } };
const elements = Array.isArray(candidate.elements)
? candidate.elements
: Array.isArray(candidate.body?.elements)
? candidate.body!.elements
: null;
if (!elements) {
return "[Interactive Card]";
}
const texts: string[] = [];
for (const element of candidate.elements) {
for (const element of elements) {
if (!element || typeof element !== "object") {
continue;
}
@ -177,7 +184,7 @@ function parseInteractiveCardContent(parsed: unknown): string {
return texts.join("\n").trim() || "[Interactive Card]";
}
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
function parseFeishuMessageContent(rawContent: string, msgType: string): string {
if (!rawContent) {
return "";
}
@ -218,6 +225,30 @@ function parseQuotedMessageContent(rawContent: string, msgType: string): string
return `[${msgType || "unknown"} message]`;
}
function parseFeishuMessageItem(
item: FeishuMessageGetItem,
fallbackMessageId?: string,
): FeishuMessageInfo {
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
return {
messageId: item.message_id ?? fallbackMessageId ?? "",
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content: parseFeishuMessageContent(rawContent, msgType),
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
threadId: item.thread_id || undefined,
};
}
/**
* Get a message by its ID.
* Useful for fetching quoted/replied message content.
@ -255,29 +286,98 @@ export async function getMessageFeishu(params: {
return null;
}
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
const content = parseQuotedMessageContent(rawContent, msgType);
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content,
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
};
return parseFeishuMessageItem(item, messageId);
} catch {
return null;
}
}
export type FeishuThreadMessageInfo = {
messageId: string;
senderId?: string;
senderType?: string;
content: string;
contentType: string;
createTime?: number;
};
/**
* List messages in a Feishu thread (topic).
* Uses container_id_type=thread to directly query thread messages,
* which includes both the root message and all replies (including bot replies).
*/
export async function listFeishuThreadMessages(params: {
cfg: ClawdbotConfig;
threadId: string;
currentMessageId?: string;
/** Exclude the root message (already provided separately as ThreadStarterBody). */
rootMessageId?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuThreadMessageInfo[]> {
const { cfg, threadId, currentMessageId, rootMessageId, limit = 20, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const response = (await client.im.message.list({
params: {
container_id_type: "thread",
container_id: threadId,
// Fetch newest messages first so long threads keep the most recent turns.
// Results are reversed below to restore chronological order.
sort_type: "ByCreateTimeDesc",
page_size: Math.min(limit + 1, 50),
},
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<
{
message_id?: string;
root_id?: string;
parent_id?: string;
} & FeishuMessageGetItem
>;
};
};
if (response.code !== 0) {
throw new Error(
`Feishu thread list failed: code=${response.code} msg=${response.msg ?? "unknown"}`,
);
}
const items = response.data?.items ?? [];
const results: FeishuThreadMessageInfo[] = [];
for (const item of items) {
if (currentMessageId && item.message_id === currentMessageId) continue;
if (rootMessageId && item.message_id === rootMessageId) continue;
const parsed = parseFeishuMessageItem(item);
results.push({
messageId: parsed.messageId,
senderId: parsed.senderId,
senderType: parsed.senderType,
content: parsed.content,
contentType: parsed.contentType,
createTime: parsed.createTime,
});
if (results.length >= limit) break;
}
// Restore chronological order (oldest first) since we fetched newest-first.
results.reverse();
return results;
}
export type SendFeishuMessageParams = {
cfg: ClawdbotConfig;
to: string;

View File

@ -72,6 +72,8 @@ export type FeishuMessageInfo = {
content: string;
contentType: string;
createTime?: number;
/** Feishu thread ID (omt_xxx) — present when the message belongs to a topic thread. */
threadId?: string;
};
export type FeishuProbeResult = BaseProbeResult<string> & {

View File

@ -234,6 +234,8 @@
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"clean:dist": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
"config:docs:gen": "node --import tsx scripts/generate-config-doc-baseline.ts --write",
"deadcode:ci": "pnpm deadcode:report:ci:knip",
"deadcode:knip": "pnpm dlx knip --config knip.config.ts --isolate-workspaces --production --no-progress --reporter compact --files --dependencies",
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
@ -299,7 +301,7 @@
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "node --import tsx scripts/release-check.ts",
"release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"start": "node scripts/run-node.mjs",
"test": "node scripts/test-parallel.mjs",
@ -450,7 +452,8 @@
"node-domexception": "npm:@nolyfill/domexception@^1.0.28",
"@sinclair/typebox": "0.34.48",
"tar": "7.5.11",
"tough-cookie": "4.1.3"
"tough-cookie": "4.1.3",
"yauzl": "3.2.1"
},
"onlyBuiltDependencies": [
"@lydell/node-pty",

19
pnpm-lock.yaml generated
View File

@ -18,6 +18,7 @@ overrides:
'@sinclair/typebox': 0.34.48
tar: 7.5.11
tough-cookie: 4.1.3
yauzl: 3.2.1
packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8=
@ -4440,9 +4441,6 @@ packages:
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -6805,8 +6803,9 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yauzl@3.2.1:
resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==}
engines: {node: '>=12'}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
@ -11574,7 +11573,7 @@ snapshots:
dependencies:
debug: 4.4.3
get-stream: 5.2.0
yauzl: 2.10.0
yauzl: 3.2.1
optionalDependencies:
'@types/yauzl': 2.10.3
transitivePeerDependencies:
@ -11606,10 +11605,6 @@ snapshots:
dependencies:
reusify: 1.1.0
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -14279,10 +14274,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yauzl@2.10.0:
yauzl@3.2.1:
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
pend: 1.2.0
yoctocolors@2.1.2: {}

View File

@ -88,6 +88,8 @@ fi
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" \
-c "$A2UI_APP_DIR/rolldown.config.mjs"

View File

@ -0,0 +1,44 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { writeConfigDocBaselineStatefile } from "../src/config/doc-baseline.js";
const args = new Set(process.argv.slice(2));
const checkOnly = args.has("--check");
if (checkOnly && args.has("--write")) {
console.error("Use either --check or --write, not both.");
process.exit(1);
}
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = await writeConfigDocBaselineStatefile({
repoRoot,
check: checkOnly,
});
if (checkOnly) {
if (!result.changed) {
console.log(
`OK ${path.relative(repoRoot, result.jsonPath)} ${path.relative(repoRoot, result.statefilePath)}`,
);
process.exit(0);
}
console.error(
[
"Config baseline drift detected.",
`Expected current: ${path.relative(repoRoot, result.jsonPath)}`,
`Expected current: ${path.relative(repoRoot, result.statefilePath)}`,
"If this config-surface change is intentional, run `pnpm config:docs:gen` and commit the updated baseline files.",
"If not intentional, treat this as docs drift or a possible breaking config change and fix the schema/help changes first.",
].join("\n"),
);
process.exit(1);
}
console.log(
[
`Wrote ${path.relative(repoRoot, result.jsonPath)}`,
`Wrote ${path.relative(repoRoot, result.statefilePath)}`,
].join("\n"),
);

View File

@ -157,11 +157,9 @@ describe("createOpenClawCodingTools", () => {
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
});
it("mentions Chrome extension relay in browser tool description", () => {
it("mentions user browser profile in browser tool description", () => {
const browser = createBrowserTool();
expect(browser.description).toMatch(/Chrome extension/i);
expect(browser.description).toMatch(/profile="user"/i);
expect(browser.description).toMatch(/profile="chrome-relay"/i);
});
it("keeps browser tool schema properties after normalization", () => {
const browser = defaultTools.find((tool) => tool.name === "browser");

View File

@ -74,7 +74,7 @@ function formatConsoleToolResult(result: {
}
function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean {
if (profile !== "chrome-relay" && profile !== "chrome") {
if (profile !== "chrome-relay" && profile !== "chrome" && profile !== "user") {
return false;
}
const msg = String(err);
@ -314,7 +314,7 @@ export async function executeActAction(params: {
})) as { tabs?: unknown[] }
).tabs ?? [])
: await browserTabs(baseUrl, { profile }).catch(() => []);
// Some Chrome relay targetIds can go stale between snapshots and actions.
// Some user-browser targetIds can go stale between snapshots and actions.
// Only retry safe read-only actions, and only when exactly one tab remains attached.
if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) {
try {
@ -334,13 +334,17 @@ export async function executeActAction(params: {
}
}
if (!tabs.length) {
// Extension relay profiles need the toolbar icon click; Chrome MCP just needs Chrome running.
const isRelayProfile = profile === "chrome-relay" || profile === "chrome";
throw new Error(
"No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.",
isRelayProfile
? "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry."
: `No Chrome tabs found for profile="${profile}". Make sure Chrome (v146+) is running and has open tabs, then retry.`,
{ cause: err },
);
}
throw new Error(
`Chrome tab not found (stale targetId?). Run action=tabs profile="chrome-relay" and use one of the returned targetIds.`,
`Chrome tab not found (stale targetId?). Run action=tabs profile="${profile}" and use one of the returned targetIds.`,
{ cause: err },
);
}

View File

@ -287,9 +287,9 @@ describe("browser tool snapshot maxChars", () => {
expect(opts?.mode).toBeUndefined();
});
it("defaults to host when using profile=chrome-relay (even in sandboxed sessions)", async () => {
it("defaults to host when using an explicit extension relay profile (even in sandboxed sessions)", async () => {
setResolvedBrowserProfiles({
"chrome-relay": {
relay: {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
color: "#0066CC",
@ -298,14 +298,14 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.("call-1", {
action: "snapshot",
profile: "chrome-relay",
profile: "relay",
snapshotFormat: "ai",
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "chrome-relay",
profile: "relay",
}),
);
});
@ -366,12 +366,12 @@ describe("browser tool snapshot maxChars", () => {
it("lets the server choose snapshot format when the user does not request one", async () => {
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "snapshot", profile: "chrome-relay" });
await tool.execute?.("call-1", { action: "snapshot", profile: "user" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "chrome-relay",
profile: "user",
}),
);
const opts = browserClientMocks.browserSnapshot.mock.calls.at(-1)?.[1] as
@ -438,21 +438,17 @@ describe("browser tool snapshot maxChars", () => {
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it("keeps chrome-relay profile on host when node proxy is available", async () => {
it("keeps user profile on host when node proxy is available", async () => {
mockSingleBrowserProxyNode();
setResolvedBrowserProfiles({
"chrome-relay": {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
color: "#0066CC",
},
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "chrome-relay" });
await tool.execute?.("call-1", { action: "status", profile: "user" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ profile: "chrome-relay" }),
expect.objectContaining({ profile: "user" }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
@ -745,7 +741,7 @@ describe("browser tool external content wrapping", () => {
describe("browser tool act stale target recovery", () => {
registerBrowserToolAfterEachReset();
it("retries safe chrome-relay act once without targetId when exactly one tab remains", async () => {
it("retries safe user-browser act once without targetId when exactly one tab remains", async () => {
browserActionsMocks.browserAct
.mockRejectedValueOnce(new Error("404: tab not found"))
.mockResolvedValueOnce({ ok: true });
@ -754,7 +750,7 @@ describe("browser tool act stale target recovery", () => {
const tool = createBrowserTool();
const result = await tool.execute?.("call-1", {
action: "act",
profile: "chrome-relay",
profile: "user",
request: {
kind: "hover",
targetId: "stale-tab",
@ -767,18 +763,18 @@ describe("browser tool act stale target recovery", () => {
1,
undefined,
expect.objectContaining({ targetId: "stale-tab", kind: "hover", ref: "btn-1" }),
expect.objectContaining({ profile: "chrome-relay" }),
expect.objectContaining({ profile: "user" }),
);
expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith(
2,
undefined,
expect.not.objectContaining({ targetId: expect.anything() }),
expect.objectContaining({ profile: "chrome-relay" }),
expect.objectContaining({ profile: "user" }),
);
expect(result?.details).toMatchObject({ ok: true });
});
it("does not retry mutating chrome-relay act requests without targetId", async () => {
it("does not retry mutating user-browser act requests without targetId", async () => {
browserActionsMocks.browserAct.mockRejectedValueOnce(new Error("404: tab not found"));
browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]);
@ -786,14 +782,14 @@ describe("browser tool act stale target recovery", () => {
await expect(
tool.execute?.("call-1", {
action: "act",
profile: "chrome-relay",
profile: "user",
request: {
kind: "click",
targetId: "stale-tab",
ref: "btn-1",
},
}),
).rejects.toThrow(/Run action=tabs profile="chrome-relay"/i);
).rejects.toThrow(/Run action=tabs profile="user"/i);
expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(1);
});

View File

@ -293,10 +293,6 @@ function shouldPreferHostForProfile(profileName: string | undefined) {
return capabilities.requiresRelay || capabilities.usesChromeMcp;
}
function isHostOnlyProfileName(profileName: string | undefined) {
return profileName === "user" || profileName === "chrome-relay";
}
export function createBrowserTool(opts?: {
sandboxBridgeUrl?: string;
allowHostControl?: boolean;
@ -311,11 +307,8 @@ export function createBrowserTool(opts?: {
description: [
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
"Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).",
'For the logged-in user browser on the local host, prefer profile="user". Use it only when existing logins/cookies matter and the user is present to click/approve any browser attach prompt.',
'Use profile="chrome-relay" only for the Chrome extension / Browser Relay / toolbar-button attach-tab flow, or when the user explicitly asks for the extension relay.',
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS prefer profile="chrome-relay". Otherwise prefer profile="user" over the extension relay for user-browser work.',
'For the logged-in user browser on the local host, use profile="user". Chrome (v146+) must be running. Use only when existing logins/cookies matter and the user is present.',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
'User-browser flows need user interaction: profile="user" may require approving a browser attach prompt; profile="chrome-relay" needs the user to click the OpenClaw Browser Relay toolbar icon on the tab (badge ON). If user presence is unclear, ask first.',
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
@ -333,7 +326,9 @@ export function createBrowserTool(opts?: {
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
}
if (isHostOnlyProfileName(profile)) {
// User-browser profiles (existing-session, extension relay) are host-only.
const isUserBrowserProfile = shouldPreferHostForProfile(profile);
if (isUserBrowserProfile) {
if (requestedNode || target === "node") {
throw new Error(`profile="${profile}" only supports the local host browser.`);
}
@ -342,10 +337,9 @@ export function createBrowserTool(opts?: {
`profile="${profile}" cannot use the sandbox browser; use target="host" or omit target.`,
);
}
}
if (!target && !requestedNode && shouldPreferHostForProfile(profile)) {
// Local host user-browser profiles should not silently bind to sandbox/node browsers.
target = "host";
if (!target && !requestedNode) {
target = "host";
}
}
const nodeTarget = await resolveBrowserNodeTarget({

View File

@ -266,11 +266,6 @@ describe("browser server-context listKnownProfileNames", () => {
]),
};
expect(listKnownProfileNames(state).toSorted()).toEqual([
"chrome-relay",
"openclaw",
"stale-removed",
"user",
]);
expect(listKnownProfileNames(state).toSorted()).toEqual(["openclaw", "stale-removed", "user"]);
});
});

View File

@ -193,7 +193,7 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
await client.close().catch(() => {});
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
`Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection. ` +
`Make sure Chrome (v146+) is running. ` +
`Details: ${String(err)}`,
);
}

View File

@ -26,10 +26,8 @@ describe("browser config", () => {
expect(user?.driver).toBe("existing-session");
expect(user?.cdpPort).toBe(0);
expect(user?.cdpUrl).toBe("");
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(18792);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:18792");
// chrome-relay is no longer auto-created
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
});
@ -38,10 +36,7 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.controlPort).toBe(19003);
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(19004);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19004");
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19012);
@ -53,10 +48,7 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: undefined }, () => {
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
expect(resolved.controlPort).toBe(19013);
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(19014);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19014");
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19022);
@ -209,16 +201,6 @@ describe("browser config", () => {
);
});
it("does not add the built-in chrome-relay profile if the derived relay port is already used", () => {
const resolved = resolveBrowserConfig({
profiles: {
openclaw: { cdpPort: 18792, color: "#FF4500" },
},
});
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
expect(resolved.defaultProfile).toBe("openclaw");
});
it("defaults extraArgs to empty array when not provided", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.extraArgs).toEqual([]);
@ -307,6 +289,7 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
work: { cdpPort: 18801, color: "#0066CC" },
},
});
@ -317,7 +300,7 @@ describe("browser config", () => {
const managed = resolveProfile(resolved, "openclaw")!;
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
const extension = resolveProfile(resolved, "chrome-relay")!;
const extension = resolveProfile(resolved, "relay")!;
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
const work = resolveProfile(resolved, "work")!;
@ -358,17 +341,17 @@ describe("browser config", () => {
it("explicit defaultProfile config overrides defaults in headless mode", () => {
const resolved = resolveBrowserConfig({
headless: true,
defaultProfile: "chrome-relay",
defaultProfile: "user",
});
expect(resolved.defaultProfile).toBe("chrome-relay");
expect(resolved.defaultProfile).toBe("user");
});
it("explicit defaultProfile config overrides defaults in noSandbox mode", () => {
const resolved = resolveBrowserConfig({
noSandbox: true,
defaultProfile: "chrome-relay",
defaultProfile: "user",
});
expect(resolved.defaultProfile).toBe("chrome-relay");
expect(resolved.defaultProfile).toBe("user");
});
it("allows custom profile as default even in headless mode", () => {

View File

@ -14,7 +14,7 @@ import {
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
import { CDP_PORT_RANGE_START } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
@ -197,36 +197,6 @@ function ensureDefaultUserBrowserProfile(
return result;
}
/**
* Ensure a built-in "chrome-relay" profile exists for the Chrome extension relay.
*
* Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile.
* It points at the local relay CDP endpoint (controlPort + 1).
*/
function ensureDefaultChromeRelayProfile(
profiles: Record<string, BrowserProfileConfig>,
controlPort: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result["chrome-relay"]) {
return result;
}
const relayPort = controlPort + 1;
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) {
return result;
}
// Avoid adding the built-in profile if the derived relay port is already used by another profile
// (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP).
if (getUsedPorts(result).has(relayPort)) {
return result;
}
result["chrome-relay"] = {
driver: "extension",
cdpUrl: `http://127.0.0.1:${relayPort}`,
color: "#00AA00",
};
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig,
@ -286,17 +256,14 @@ export function resolveBrowserConfig(
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultChromeRelayProfile(
ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
const profiles = ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
controlPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";

View File

@ -3,10 +3,15 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults chrome-relay snapshots to aria when format is omitted", () => {
const resolved = resolveBrowserConfig({});
const profile = resolveProfile(resolved, "chrome-relay");
it("defaults extension relay snapshots to aria when format is omitted", () => {
const resolved = resolveBrowserConfig({
profiles: {
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
},
});
const profile = resolveProfile(resolved, "relay");
expect(profile).toBeTruthy();
expect(profile?.driver).toBe("extension");
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,

View File

@ -0,0 +1,160 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildConfigDocBaseline,
collectConfigDocBaselineEntries,
dedupeConfigDocBaselineEntries,
normalizeConfigDocBaselineHelpPath,
renderConfigDocBaselineStatefile,
writeConfigDocBaselineStatefile,
} from "./doc-baseline.js";
describe("config doc baseline", () => {
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map(async (tempRoot) => {
await fs.rm(tempRoot, { recursive: true, force: true });
}),
);
});
it("is deterministic across repeated runs", async () => {
const first = await renderConfigDocBaselineStatefile();
const second = await renderConfigDocBaselineStatefile();
expect(second.json).toBe(first.json);
expect(second.jsonl).toBe(first.jsonl);
});
it("normalizes array and record paths to wildcard form", async () => {
const baseline = await buildConfigDocBaseline();
const paths = new Set(baseline.entries.map((entry) => entry.path));
expect(paths.has("session.sendPolicy.rules.*.match.keyPrefix")).toBe(true);
expect(paths.has("env.*")).toBe(true);
expect(normalizeConfigDocBaselineHelpPath("agents.list[].skills")).toBe("agents.list.*.skills");
});
it("includes core, channel, and plugin config metadata", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("gateway.auth.token")).toMatchObject({
kind: "core",
sensitive: true,
});
expect(byPath.get("channels.telegram.botToken")).toMatchObject({
kind: "channel",
sensitive: true,
});
expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({
kind: "plugin",
sensitive: true,
});
});
it("preserves help text and tags from merged schema hints", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
const tokenEntry = byPath.get("gateway.auth.token");
expect(tokenEntry?.help).toContain("gateway access");
expect(tokenEntry?.tags).toContain("auth");
expect(tokenEntry?.tags).toContain("security");
});
it("matches array help hints that still use [] notation", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({
help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"),
sensitive: false,
});
});
it("walks union branches for nested config keys", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("bindings.*")).toMatchObject({
hasChildren: true,
});
expect(byPath.get("bindings.*.type")).toBeDefined();
expect(byPath.get("bindings.*.match.channel")).toBeDefined();
expect(byPath.get("bindings.*.match.peer.id")).toBeDefined();
});
it("merges tuple item metadata instead of dropping earlier entries", () => {
const entries = dedupeConfigDocBaselineEntries(
collectConfigDocBaselineEntries(
{
type: "array",
items: [
{
type: "string",
enum: ["alpha"],
},
{
type: "number",
enum: [42],
},
],
},
{},
"tupleValues",
),
);
const tupleEntry = new Map(entries.map((entry) => [entry.path, entry])).get("tupleValues.*");
expect(tupleEntry).toMatchObject({
type: ["number", "string"],
});
expect(tupleEntry?.enumValues).toEqual(expect.arrayContaining([42, "alpha"]));
expect(tupleEntry?.enumValues).toHaveLength(2);
});
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-"));
tempRoots.push(tempRoot);
const initial = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
});
expect(initial.wrote).toBe(true);
const current = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
});
expect(current.changed).toBe(false);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.json"),
'{"generatedBy":"broken","entries":[]}\n',
"utf8",
);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.jsonl"),
'{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n',
"utf8",
);
const stale = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

578
src/config/doc-baseline.ts Normal file
View File

@ -0,0 +1,578 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { ChannelPlugin } from "../channels/plugins/index.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { FIELD_HELP } from "./schema.help.js";
import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type JsonSchemaNode = Record<string, unknown>;
type JsonSchemaObject = JsonSchemaNode & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
enum?: unknown[];
default?: unknown;
deprecated?: boolean;
anyOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
};
export type ConfigDocBaselineKind = "core" | "channel" | "plugin";
export type ConfigDocBaselineEntry = {
path: string;
kind: ConfigDocBaselineKind;
type?: string | string[];
required: boolean;
enumValues?: JsonValue[];
defaultValue?: JsonValue;
deprecated: boolean;
sensitive: boolean;
tags: string[];
label?: string;
help?: string;
hasChildren: boolean;
};
export type ConfigDocBaseline = {
generatedBy: "scripts/generate-config-doc-baseline.ts";
entries: ConfigDocBaselineEntry[];
};
export type ConfigDocBaselineStatefileRender = {
json: string;
jsonl: string;
baseline: ConfigDocBaseline;
};
export type ConfigDocBaselineStatefileWriteResult = {
changed: boolean;
wrote: boolean;
jsonPath: string;
statefilePath: string;
};
const GENERATED_BY = "scripts/generate-config-doc-baseline.ts" as const;
const DEFAULT_JSON_OUTPUT = "docs/.generated/config-baseline.json";
const DEFAULT_STATEFILE_OUTPUT = "docs/.generated/config-baseline.jsonl";
function resolveRepoRoot(): string {
const fromPackage = resolveOpenClawPackageRootSync({
cwd: path.dirname(fileURLToPath(import.meta.url)),
moduleUrl: import.meta.url,
});
if (fromPackage) {
return fromPackage;
}
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}
function normalizeBaselinePath(rawPath: string): string {
return rawPath
.trim()
.replace(/\[\]/g, ".*")
.replace(/\[(\*|\d+)\]/g, ".*")
.replace(/^\.+|\.+$/g, "")
.replace(/\.+/g, ".");
}
function normalizeJsonValue(value: unknown): JsonValue | undefined {
if (value === null) {
return null;
}
if (typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (Array.isArray(value)) {
const normalized = value
.map((entry) => normalizeJsonValue(entry))
.filter((entry): entry is JsonValue => entry !== undefined);
return normalized;
}
if (!value || typeof value !== "object") {
return undefined;
}
const entries = Object.entries(value as Record<string, unknown>)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => {
const normalized = normalizeJsonValue(entry);
return normalized === undefined ? null : ([key, normalized] as const);
})
.filter((entry): entry is readonly [string, JsonValue] => entry !== null);
return Object.fromEntries(entries);
}
function normalizeEnumValues(values: unknown[] | undefined): JsonValue[] | undefined {
if (!values) {
return undefined;
}
const normalized = values
.map((entry) => normalizeJsonValue(entry))
.filter((entry): entry is JsonValue => entry !== undefined);
return normalized.length > 0 ? normalized : undefined;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaObject;
}
function schemaHasChildren(schema: JsonSchemaObject): boolean {
if (schema.properties && Object.keys(schema.properties).length > 0) {
return true;
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
return true;
}
if (Array.isArray(schema.items)) {
return schema.items.some((entry) => typeof entry === "object" && entry !== null);
}
for (const branch of [schema.oneOf, schema.anyOf, schema.allOf]) {
if (branch?.some((entry) => entry && typeof entry === "object" && schemaHasChildren(entry))) {
return true;
}
}
return Boolean(schema.items && typeof schema.items === "object");
}
function splitHintLookupPath(path: string): string[] {
const normalized = normalizeBaselinePath(path);
return normalized ? normalized.split(".").filter(Boolean) : [];
}
function resolveUiHintMatch(
uiHints: ConfigSchemaResponse["uiHints"],
path: string,
): ConfigSchemaResponse["uiHints"][string] | undefined {
const targetParts = splitHintLookupPath(path);
let bestMatch:
| {
hint: ConfigSchemaResponse["uiHints"][string];
wildcardCount: number;
}
| undefined;
for (const [hintPath, hint] of Object.entries(uiHints)) {
const hintParts = splitHintLookupPath(hintPath);
if (hintParts.length !== targetParts.length) {
continue;
}
let wildcardCount = 0;
let matches = true;
for (let index = 0; index < hintParts.length; index += 1) {
const hintPart = hintParts[index];
const targetPart = targetParts[index];
if (hintPart === targetPart) {
continue;
}
if (hintPart === "*") {
wildcardCount += 1;
continue;
}
matches = false;
break;
}
if (!matches) {
continue;
}
if (!bestMatch || wildcardCount < bestMatch.wildcardCount) {
bestMatch = { hint, wildcardCount };
}
}
return bestMatch?.hint;
}
function normalizeTypeValue(value: string | string[] | undefined): string | string[] | undefined {
if (!value) {
return undefined;
}
if (Array.isArray(value)) {
const normalized = [...new Set(value)].toSorted((left, right) => left.localeCompare(right));
return normalized.length === 1 ? normalized[0] : normalized;
}
return value;
}
function mergeTypeValues(
left: string | string[] | undefined,
right: string | string[] | undefined,
): string | string[] | undefined {
const merged = new Set<string>();
for (const value of [left, right]) {
if (!value) {
continue;
}
if (Array.isArray(value)) {
for (const entry of value) {
merged.add(entry);
}
continue;
}
merged.add(value);
}
return normalizeTypeValue([...merged]);
}
function areJsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function mergeJsonValueArrays(
left: JsonValue[] | undefined,
right: JsonValue[] | undefined,
): JsonValue[] | undefined {
if (!left?.length) {
return right ? [...right] : undefined;
}
if (!right?.length) {
return [...left];
}
const merged = new Map<string, JsonValue>();
for (const value of [...left, ...right]) {
merged.set(JSON.stringify(value), value);
}
return [...merged.entries()]
.toSorted(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([, value]) => value);
}
function mergeConfigDocBaselineEntry(
current: ConfigDocBaselineEntry,
next: ConfigDocBaselineEntry,
): ConfigDocBaselineEntry {
const label = current.label === next.label ? current.label : (current.label ?? next.label);
const help = current.help === next.help ? current.help : (current.help ?? next.help);
const defaultValue = areJsonValuesEqual(current.defaultValue, next.defaultValue)
? (current.defaultValue ?? next.defaultValue)
: undefined;
return {
path: current.path,
kind: current.kind,
type: mergeTypeValues(current.type, next.type),
required: current.required && next.required,
enumValues: mergeJsonValueArrays(current.enumValues, next.enumValues),
defaultValue,
deprecated: current.deprecated || next.deprecated,
sensitive: current.sensitive || next.sensitive,
tags: [...new Set([...current.tags, ...next.tags])].toSorted((left, right) =>
left.localeCompare(right),
),
label,
help,
hasChildren: current.hasChildren || next.hasChildren,
};
}
function resolveEntryKind(configPath: string): ConfigDocBaselineKind {
if (configPath.startsWith("channels.")) {
return "channel";
}
if (configPath.startsWith("plugins.entries.")) {
return "plugin";
}
return "core";
}
async function resolveFirstExistingPath(candidates: string[]): Promise<string | null> {
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch {
// Keep scanning for other source file variants.
}
}
return null;
}
function isChannelPlugin(value: unknown): value is ChannelPlugin {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as { id?: unknown; meta?: unknown; capabilities?: unknown };
return typeof candidate.id === "string" && typeof candidate.meta === "object";
}
async function importChannelPluginModule(rootDir: string): Promise<ChannelPlugin> {
const modulePath = await resolveFirstExistingPath([
path.join(rootDir, "src", "channel.ts"),
path.join(rootDir, "src", "channel.js"),
path.join(rootDir, "src", "plugin.ts"),
path.join(rootDir, "src", "plugin.js"),
path.join(rootDir, "src", "index.ts"),
path.join(rootDir, "src", "index.js"),
path.join(rootDir, "src", "channel.mts"),
path.join(rootDir, "src", "channel.mjs"),
path.join(rootDir, "src", "plugin.mts"),
path.join(rootDir, "src", "plugin.mjs"),
]);
if (!modulePath) {
throw new Error(`channel source not found under ${rootDir}`);
}
const imported = (await import(pathToFileURL(modulePath).href)) as Record<string, unknown>;
for (const value of Object.values(imported)) {
if (isChannelPlugin(value)) {
return value;
}
if (typeof value === "function" && value.length === 0) {
const resolved = value();
if (isChannelPlugin(resolved)) {
return resolved;
}
}
}
throw new Error(`channel plugin export not found in ${modulePath}`);
}
async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse> {
const repoRoot = resolveRepoRoot();
const env = {
...process.env,
HOME: os.tmpdir(),
OPENCLAW_STATE_DIR: path.join(os.tmpdir(), "openclaw-config-doc-baseline-state"),
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"),
};
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
env,
config: {},
});
const channelPlugins = await Promise.all(
manifestRegistry.plugins
.filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0)
.map(async (plugin) => ({
id: plugin.id,
channel: await importChannelPluginModule(plugin.rootDir),
})),
);
return buildConfigSchema({
plugins: manifestRegistry.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configSchema,
})),
channels: channelPlugins.map((entry) => ({
id: entry.channel.id,
label: entry.channel.meta.label,
description: entry.channel.meta.blurb,
configSchema: entry.channel.configSchema?.schema,
configUiHints: entry.channel.configSchema?.uiHints,
})),
});
}
export function collectConfigDocBaselineEntries(
schema: JsonSchemaObject,
uiHints: ConfigSchemaResponse["uiHints"],
pathPrefix = "",
required = false,
entries: ConfigDocBaselineEntry[] = [],
): ConfigDocBaselineEntry[] {
const normalizedPath = normalizeBaselinePath(pathPrefix);
if (normalizedPath) {
const hint = resolveUiHintMatch(uiHints, normalizedPath);
entries.push({
path: normalizedPath,
kind: resolveEntryKind(normalizedPath),
type: normalizeTypeValue(schema.type),
required,
enumValues: normalizeEnumValues(schema.enum),
defaultValue: normalizeJsonValue(schema.default),
deprecated: schema.deprecated === true,
sensitive: hint?.sensitive === true,
tags: [...(hint?.tags ?? [])].toSorted((left, right) => left.localeCompare(right)),
label: hint?.label,
help: hint?.help,
hasChildren: schemaHasChildren(schema),
});
}
const requiredKeys = new Set(schema.required ?? []);
for (const key of Object.keys(schema.properties ?? {}).toSorted((left, right) =>
left.localeCompare(right),
)) {
const child = asSchemaObject(schema.properties?.[key]);
if (!child) {
continue;
}
const childPath = normalizedPath ? `${normalizedPath}.${key}` : key;
collectConfigDocBaselineEntries(child, uiHints, childPath, requiredKeys.has(key), entries);
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
const wildcard = asSchemaObject(schema.additionalProperties);
if (wildcard) {
const wildcardPath = normalizedPath ? `${normalizedPath}.*` : "*";
collectConfigDocBaselineEntries(wildcard, uiHints, wildcardPath, false, entries);
}
}
if (Array.isArray(schema.items)) {
for (const item of schema.items) {
const child = asSchemaObject(item);
if (!child) {
continue;
}
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
collectConfigDocBaselineEntries(child, uiHints, itemPath, false, entries);
}
} else if (schema.items && typeof schema.items === "object") {
const itemSchema = asSchemaObject(schema.items);
if (itemSchema) {
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
collectConfigDocBaselineEntries(itemSchema, uiHints, itemPath, false, entries);
}
}
for (const branchSchema of [schema.oneOf, schema.anyOf, schema.allOf]) {
for (const branch of branchSchema ?? []) {
const child = asSchemaObject(branch);
if (!child) {
continue;
}
collectConfigDocBaselineEntries(child, uiHints, normalizedPath, required, entries);
}
}
return entries;
}
export function dedupeConfigDocBaselineEntries(
entries: ConfigDocBaselineEntry[],
): ConfigDocBaselineEntry[] {
const byPath = new Map<string, ConfigDocBaselineEntry>();
for (const entry of entries) {
const current = byPath.get(entry.path);
byPath.set(entry.path, current ? mergeConfigDocBaselineEntry(current, entry) : entry);
}
return [...byPath.values()].toSorted((left, right) => left.path.localeCompare(right.path));
}
export async function buildConfigDocBaseline(): Promise<ConfigDocBaseline> {
const response = await loadBundledConfigSchemaResponse();
const schemaRoot = asSchemaObject(response.schema);
if (!schemaRoot) {
throw new Error("config schema root is not an object");
}
const entries = dedupeConfigDocBaselineEntries(
collectConfigDocBaselineEntries(schemaRoot, response.uiHints),
);
return {
generatedBy: GENERATED_BY,
entries,
};
}
export async function renderConfigDocBaselineStatefile(
baseline?: ConfigDocBaseline,
): Promise<ConfigDocBaselineStatefileRender> {
const resolvedBaseline = baseline ?? (await buildConfigDocBaseline());
const json = `${JSON.stringify(resolvedBaseline, null, 2)}\n`;
const metadataLine = JSON.stringify({
generatedBy: GENERATED_BY,
recordType: "meta",
totalPaths: resolvedBaseline.entries.length,
});
const entryLines = resolvedBaseline.entries.map((entry) =>
JSON.stringify({
recordType: "path",
...entry,
}),
);
return {
json,
jsonl: `${[metadataLine, ...entryLines].join("\n")}\n`,
baseline: resolvedBaseline,
};
}
async function readIfExists(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf8");
} catch {
return null;
}
}
async function writeIfChanged(filePath: string, next: string): Promise<boolean> {
const current = await readIfExists(filePath);
if (current === next) {
return false;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, next, "utf8");
return true;
}
export async function writeConfigDocBaselineStatefile(params?: {
repoRoot?: string;
check?: boolean;
jsonPath?: string;
statefilePath?: string;
}): Promise<ConfigDocBaselineStatefileWriteResult> {
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
const jsonPath = path.resolve(repoRoot, params?.jsonPath ?? DEFAULT_JSON_OUTPUT);
const statefilePath = path.resolve(repoRoot, params?.statefilePath ?? DEFAULT_STATEFILE_OUTPUT);
const rendered = await renderConfigDocBaselineStatefile();
const currentJson = await readIfExists(jsonPath);
const currentStatefile = await readIfExists(statefilePath);
const changed = currentJson !== rendered.json || currentStatefile !== rendered.jsonl;
if (params?.check) {
return {
changed,
wrote: false,
jsonPath,
statefilePath,
};
}
const wroteJson = await writeIfChanged(jsonPath, rendered.json);
const wroteStatefile = await writeIfChanged(statefilePath, rendered.jsonl);
return {
changed,
wrote: wroteJson || wroteStatefile,
jsonPath,
statefilePath,
};
}
export function normalizeConfigDocBaselineHelpPath(pathValue: string): string {
return normalizeBaselinePath(pathValue);
}
export function getNormalizedFieldHelp(): Record<string, string> {
return Object.fromEntries(
Object.entries(FIELD_HELP)
.map(([configPath, help]) => [normalizeBaselinePath(configPath), help] as const)
.toSorted(([left], [right]) => left.localeCompare(right)),
);
}

View File

@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { normalizeConfigDocBaselineHelpPath } from "./doc-baseline.js";
import { FIELD_HELP } from "./schema.help.js";
import {
describeTalkSilenceTimeoutDefaults,
@ -17,8 +18,18 @@ function readRepoFile(relativePath: string): string {
describe("talk silence timeout defaults", () => {
it("keeps help text and docs aligned with the policy", () => {
const defaultsDescription = describeTalkSilenceTimeoutDefaults();
const baselineLines = readRepoFile("docs/.generated/config-baseline.jsonl")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { recordType: string; path?: string; help?: string });
const talkEntry = baselineLines.find(
(entry) =>
entry.recordType === "path" &&
entry.path === normalizeConfigDocBaselineHelpPath("talk.silenceTimeoutMs"),
);
expect(FIELD_HELP["talk.silenceTimeoutMs"]).toContain(defaultsDescription);
expect(talkEntry?.help).toContain(defaultsDescription);
expect(readRepoFile("docs/gateway/configuration-reference.md")).toContain(defaultsDescription);
expect(readRepoFile("docs/nodes/talk.md")).toContain(defaultsDescription);
});

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js";
import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts";
import type { GatewayHelloOk } from "./gateway.ts";
const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
@ -9,6 +10,7 @@ type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
options: { clientVersion?: string };
emitHello: (hello?: GatewayHelloOk) => void;
emitClose: (info: {
code: number;
reason?: string;
@ -39,6 +41,7 @@ vi.mock("./gateway.ts", () => {
constructor(
private opts: {
clientVersion?: string;
onHello?: (hello: GatewayHelloOk) => void;
onClose?: (info: {
code: number;
reason: string;
@ -52,6 +55,15 @@ vi.mock("./gateway.ts", () => {
start: this.start,
stop: this.stop,
options: { clientVersion: this.opts.clientVersion },
emitHello: (hello) => {
this.opts.onHello?.(
hello ?? {
type: "hello-ok",
protocol: 3,
snapshot: {},
},
);
},
emitClose: (info) => {
this.opts.onClose?.({
code: info.code,
@ -356,6 +368,93 @@ describe("connectGateway", () => {
expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH");
});
it("surfaces shutdown restart reasons before the socket closes", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "config change requires gateway restart (plugins.installs)",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1006 });
expect(host.lastError).toBe(
"Restarting: config change requires gateway restart (plugins.installs)",
);
expect(host.lastErrorCode).toBeNull();
});
it("clears pending shutdown messages on successful hello after reconnect", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "config change",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1006 });
expect(host.lastError).toBe("Restarting: config change");
client.emitHello();
expect(host.lastError).toBeNull();
client.emitClose({ code: 1006 });
expect(host.lastError).toBe("disconnected (1006): no reason");
});
it("keeps shutdown restart reasons on service restart closes", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "gateway restarting",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1012, reason: "service restart" });
expect(host.lastError).toBe("Restarting: gateway restarting");
expect(host.lastErrorCode).toBeNull();
});
it("prefers shutdown restart reasons over non-1012 close reasons", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitEvent({
event: "shutdown",
payload: {
reason: "gateway restarting",
restartExpectedMs: 1500,
},
});
client.emitClose({ code: 1001, reason: "going away" });
expect(host.lastError).toBe("Restarting: gateway restarting");
expect(host.lastErrorCode).toBeNull();
});
it("does not reload chat history for each live tool result event", () => {
const host = createHost();

View File

@ -91,6 +91,10 @@ type SessionDefaultsSnapshot = {
scope?: string;
};
type GatewayHostWithShutdownMessage = GatewayHost & {
pendingShutdownMessage?: string | null;
};
export function resolveControlUiClientVersion(params: {
gatewayUrl: string;
serverVersion: string | null;
@ -171,6 +175,8 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
}
export function connectGateway(host: GatewayHost) {
const shutdownHost = host as GatewayHostWithShutdownMessage;
shutdownHost.pendingShutdownMessage = null;
host.lastError = null;
host.lastErrorCode = null;
host.hello = null;
@ -195,6 +201,7 @@ export function connectGateway(host: GatewayHost) {
if (host.client !== client) {
return;
}
shutdownHost.pendingShutdownMessage = null;
host.connected = true;
host.lastError = null;
host.lastErrorCode = null;
@ -234,9 +241,10 @@ export function connectGateway(host: GatewayHost) {
: error.message;
return;
}
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
host.lastError =
shutdownHost.pendingShutdownMessage ?? `disconnected (${code}): ${reason || "no reason"}`;
} else {
host.lastError = null;
host.lastError = shutdownHost.pendingShutdownMessage ?? null;
host.lastErrorCode = null;
}
},
@ -347,6 +355,22 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
return;
}
if (evt.event === "shutdown") {
const payload = evt.payload as { reason?: unknown; restartExpectedMs?: unknown } | undefined;
const reason =
payload && typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: "gateway stopping";
const shutdownMessage =
typeof payload?.restartExpectedMs === "number"
? `Restarting: ${reason}`
: `Disconnected: ${reason}`;
(host as GatewayHostWithShutdownMessage).pendingShutdownMessage = shutdownMessage;
host.lastError = shutdownMessage;
host.lastErrorCode = null;
return;
}
if (evt.event === "cron" && host.tab === "cron") {
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
}