From 01d3505d7c0faef5248576525d927959f1e5ba76 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Tue, 16 Jun 2026 13:59:28 +0800
Subject: [PATCH 01/24] fix(auto-reply): redact secrets in /debug show and
/debug set output (#93333)
PR #88496 routed /config show and /config set chat output through the
shared schema-aware redaction path, but the sibling /debug commands in
the same handler were left untouched. /debug show JSON-stringified the
full runtime override tree verbatim and /debug set echoed the raw value,
so a secret-shaped override (e.g. gateway.auth.token, channels.*.botToken)
set via /debug set was rendered in plaintext to chat-visible output.
Apply redactConfigObject(overrides, schema.uiHints) to the override tree
before rendering /debug show, and reuse formatConfigSetValueLabel for the
/debug set acknowledgement, matching the existing /config redaction
contract. Non-secret fields and env placeholders are preserved.
---
src/auto-reply/reply/commands-config.ts | 14 ++++-
src/auto-reply/reply/commands-gating.test.ts | 64 ++++++++++++++++++++
2 files changed, 75 insertions(+), 3 deletions(-)
diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts
index c07fa0fe4cc0..de59a38f3cdd 100644
--- a/src/auto-reply/reply/commands-config.ts
+++ b/src/auto-reply/reply/commands-config.ts
@@ -248,7 +248,9 @@ export const handleDebugCommand: CommandHandler = async (params, allowTextComman
reply: { text: "⚙️ Debug overrides: (none)" },
};
}
- const json = JSON.stringify(overrides, null, 2);
+ const schema = loadGatewayRuntimeConfigSchema();
+ const redactedOverrides = redactConfigObject(overrides, schema.uiHints);
+ const json = JSON.stringify(redactedOverrides, null, 2);
return {
shouldContinue: false,
reply: {
@@ -292,8 +294,14 @@ export const handleDebugCommand: CommandHandler = async (params, allowTextComman
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
};
}
- const valueLabel =
- typeof debugCommand.value === "string"
+ const parsedOverridePath = parseConfigPath(debugCommand.path);
+ const valueLabel = parsedOverridePath.path
+ ? formatConfigSetValueLabel({
+ path: parsedOverridePath.path,
+ value: debugCommand.value,
+ uiHints: loadGatewayRuntimeConfigSchema().uiHints,
+ })
+ : typeof debugCommand.value === "string"
? `"${debugCommand.value}"`
: JSON.stringify(debugCommand.value);
return {
diff --git a/src/auto-reply/reply/commands-gating.test.ts b/src/auto-reply/reply/commands-gating.test.ts
index 4287628a53a1..64c91a0e73dd 100644
--- a/src/auto-reply/reply/commands-gating.test.ts
+++ b/src/auto-reply/reply/commands-gating.test.ts
@@ -182,6 +182,20 @@ vi.mock("./debug-commands.js", () => ({
if (!raw.startsWith("/debug")) {
return null;
}
+ const parts = raw.trim().split(/\s+/);
+ const action = parts[1];
+ if (action === "set") {
+ const assignment = raw.slice(raw.indexOf(" set ") + 5).trim();
+ const equalsIndex = assignment.indexOf("=");
+ return {
+ action: "set",
+ path: assignment.slice(0, equalsIndex),
+ value: JSON.parse(assignment.slice(equalsIndex + 1)),
+ };
+ }
+ if (action === "unset") {
+ return { action: "unset", path: parts.slice(2).join(" ") };
+ }
return { action: "show" };
}),
}));
@@ -527,6 +541,56 @@ describe("command gating", () => {
expect(output).not.toContain("OPENCLAW_CONFIG_SET_CANARY_TOKEN_65623");
});
+ it("redacts secret-shaped fields from /debug show replies", async () => {
+ getConfigOverridesMock.mockReturnValueOnce({
+ gateway: {
+ auth: {
+ token: "OPENCLAW_DEBUG_SHOW_CANARY_TOKEN_65623",
+ },
+ },
+ channels: {
+ telegram: {
+ botToken: "OPENCLAW_DEBUG_SHOW_CANARY_BOT_TOKEN_65623",
+ },
+ },
+ messages: {
+ ackReaction: ":)",
+ },
+ });
+ const params = buildParams("/debug show", {
+ commands: { debug: true, text: true },
+ channels: { whatsapp: { allowFrom: ["*"] } },
+ } as OpenClawConfig);
+ params.command.senderIsOwner = true;
+
+ const result = await handleDebugCommand(params, true);
+ const output = result?.reply?.text ?? "";
+
+ expect(output).toContain("Debug overrides (memory-only)");
+ expect(output).toContain(REDACTED_SENTINEL);
+ expect(output).toContain("ackReaction");
+ expect(output).not.toContain("OPENCLAW_DEBUG_SHOW_CANARY_TOKEN_65623");
+ expect(output).not.toContain("OPENCLAW_DEBUG_SHOW_CANARY_BOT_TOKEN_65623");
+ });
+
+ it("redacts secret-shaped values from /debug set acknowledgements", async () => {
+ const params = buildParams(
+ '/debug set gateway.auth.token="OPENCLAW_DEBUG_SET_CANARY_TOKEN_65623"',
+ {
+ commands: { debug: true, text: true },
+ channels: { whatsapp: { allowFrom: ["*"] } },
+ } as OpenClawConfig,
+ );
+ params.command.senderIsOwner = true;
+
+ const result = await handleDebugCommand(params, true);
+ const output = result?.reply?.text ?? "";
+
+ expect(output).toContain("Debug override set: gateway.auth.token=");
+ expect(output).toContain(REDACTED_SENTINEL);
+ expect(output).not.toContain("OPENCLAW_DEBUG_SET_CANARY_TOKEN_65623");
+ });
+
it("returns explicit unauthorized replies for native privileged commands", async () => {
const configParams = buildParams("/config show", {
commands: { config: true, text: true },
From 2b752ac0d1f49892c98176d2ac70647a0a8c883a Mon Sep 17 00:00:00 2001
From: xydigit-sj
Date: Tue, 16 Jun 2026 13:59:36 +0800
Subject: [PATCH 02/24] fix(doctor): repair null agents.list[].workspace values
(#93105)
A literal null `workspace` field in an agent entry failed schema validation at
startup, producing a crash loop that `openclaw doctor --fix` could not recover
from because the compatibility pipeline never normalized the malformed field.
Add a narrow doctor migration that removes null `workspace` values from
`agents.list` entries and relies on the existing fallback path (defaults or
stateDir-derived workspace) at runtime.
Fixes #77718.
---
.../doctor-legacy-config.migrations.test.ts | 33 +++++++++++++++++
.../shared/legacy-config-core-migrate.ts | 35 +++++++++++++++++++
2 files changed, 68 insertions(+)
diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts
index 9deee0bafb6d..d920888991ef 100644
--- a/src/commands/doctor-legacy-config.migrations.test.ts
+++ b/src/commands/doctor-legacy-config.migrations.test.ts
@@ -187,6 +187,39 @@ describe("normalizeCompatibilityConfigValues", () => {
);
});
+ it("removes null workspace values from agents.list entries", () => {
+ const res = normalizeCompatibilityConfigValues({
+ agents: {
+ list: [
+ { id: "main", workspace: null as unknown as string },
+ { id: "beta", workspace: "/beta" },
+ { id: "gamma" },
+ ],
+ },
+ });
+
+ expect(res.config.agents?.list).toEqual([
+ { id: "main" },
+ { id: "beta", workspace: "/beta" },
+ { id: "gamma" },
+ ]);
+ expect(res.changes).toContain("Removed null workspace value from agents.list entry.");
+ });
+
+ it("does not alter agents.list when no workspace is null", () => {
+ const res = normalizeCompatibilityConfigValues({
+ agents: {
+ list: [{ id: "main", workspace: "/main" }, { id: "beta" }],
+ },
+ });
+
+ expect(res.config.agents?.list).toEqual([
+ { id: "main", workspace: "/main" },
+ { id: "beta" },
+ ]);
+ expect(res.changes.some((change) => change.includes("workspace"))).toBe(false);
+ });
+
it("removes bindings for missing configured agents", () => {
const res = normalizeCompatibilityConfigValues({
agents: {
diff --git a/src/commands/doctor/shared/legacy-config-core-migrate.ts b/src/commands/doctor/shared/legacy-config-core-migrate.ts
index 0b5b7edad9ba..2bb7a5b3d9cd 100644
--- a/src/commands/doctor/shared/legacy-config-core-migrate.ts
+++ b/src/commands/doctor/shared/legacy-config-core-migrate.ts
@@ -10,6 +10,40 @@ import {
normalizeLegacyOpenAICodexModelsAddMetadata,
} from "./legacy-config-core-normalizers.js";
+function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
+ const agents = cfg.agents?.list;
+ if (!Array.isArray(agents)) {
+ return cfg;
+ }
+
+ let repaired = 0;
+ const nextAgents = agents.map((agent) => {
+ if (agent && typeof agent === "object" && (agent as Record).workspace === null) {
+ repaired += 1;
+ const { workspace: _workspace, ...rest } = agent as Record;
+ return rest;
+ }
+ return agent;
+ });
+
+ if (repaired === 0) {
+ return cfg;
+ }
+
+ changes.push(
+ `Removed null workspace value${repaired === 1 ? "" : "s"} from agents.list entr${
+ repaired === 1 ? "y" : "ies"
+ }.`,
+ );
+ return {
+ ...cfg,
+ agents: {
+ ...cfg.agents,
+ list: nextAgents as typeof agents,
+ },
+ };
+}
+
function pruneBindingsForMissingAgents(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
const agents = cfg.agents?.list;
const bindings = cfg.bindings;
@@ -71,6 +105,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
}
next = normalizeLegacyCommandsConfig(next, changes);
next = normalizeLegacyOpenAICodexModelsAddMetadata(next, changes);
+ next = repairNullAgentWorkspaces(next, changes);
next = pruneBindingsForMissingAgents(next, changes);
return { config: next, changes };
From ccf83ace38f28a086a440088f93a75ea867b4bb6 Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:00:00 +0800
Subject: [PATCH 03/24] fix(plugins): repair missing required platform packages
---
docs/plugins/manifest.md | 8 ++
docs/plugins/sdk-setup.md | 1 +
extensions/codex/package.json | 10 +-
extensions/codex/src/manifest.test.ts | 13 +++
src/infra/npm-managed-root.test.ts | 62 ++++++++++
src/infra/npm-managed-root.ts | 81 +++++++++++--
src/plugins/install.npm-spec.test.ts | 157 ++++++++++++++++++++++++++
src/plugins/install.ts | 123 ++++++++++++++++++++
src/plugins/manifest.ts | 1 +
9 files changed, 448 insertions(+), 8 deletions(-)
diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md
index 95aa5217908f..5de3abcc661d 100644
--- a/docs/plugins/manifest.md
+++ b/docs/plugins/manifest.md
@@ -1278,6 +1278,7 @@ Important examples:
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
+| `openclaw.install.requiredPlatformPackages` | npm package aliases that must materialize when their lockfile platform constraints match the current host. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
Manifest metadata decides which provider/channel/setup choices appear in
@@ -1290,6 +1291,13 @@ registry loading for non-bundled plugin sources. Invalid values are rejected;
newer-but-valid values skip external plugins on older hosts. Bundled source
plugins are assumed to be co-versioned with the host checkout.
+`openclaw.install.requiredPlatformPackages` is for npm packages that expose
+required native binaries through optional, platform-specific aliases. List the
+bare npm package name for every supported platform alias. During npm install,
+OpenClaw verifies only the declared alias whose lockfile constraints match the
+current host. If npm reports success but omits that alias, OpenClaw retries once
+with a fresh cache and rolls back the install if the alias is still missing.
+
`openclaw.compat.pluginApi` is enforced during package install for non-bundled
plugin sources. Use it for the OpenClaw plugin SDK/runtime API floor that the
package was built against. It can be stricter than `minHostVersion` when a
diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md
index fcb1f9941236..8090e45dbf2e 100644
--- a/docs/plugins/sdk-setup.md
+++ b/docs/plugins/sdk-setup.md
@@ -163,6 +163,7 @@ Example:
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
+| `requiredPlatformPackages` | `string[]` | Required platform-specific npm aliases verified during npm install. |
diff --git a/extensions/codex/package.json b/extensions/codex/package.json
index 9ac747f19e76..4a0229fcdd68 100644
--- a/extensions/codex/package.json
+++ b/extensions/codex/package.json
@@ -23,7 +23,15 @@
"install": {
"npmSpec": "@openclaw/codex",
"defaultChoice": "npm",
- "minHostVersion": ">=2026.5.1-beta.1"
+ "minHostVersion": ">=2026.5.1-beta.1",
+ "requiredPlatformPackages": [
+ "@openai/codex-linux-x64",
+ "@openai/codex-linux-arm64",
+ "@openai/codex-darwin-x64",
+ "@openai/codex-darwin-arm64",
+ "@openai/codex-win32-x64",
+ "@openai/codex-win32-arm64"
+ ]
},
"compat": {
"pluginApi": ">=2026.6.2"
diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts
index 5a16810513f1..77afd689467f 100644
--- a/extensions/codex/src/manifest.test.ts
+++ b/extensions/codex/src/manifest.test.ts
@@ -6,6 +6,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j
type CodexPackageManifest = {
dependencies?: Record;
devDependencies?: Record;
+ openclaw?: {
+ install?: {
+ requiredPlatformPackages?: string[];
+ };
+ };
};
describe("codex package manifest", () => {
@@ -18,5 +23,13 @@ describe("codex package manifest", () => {
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
);
+ expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
+ "@openai/codex-linux-x64",
+ "@openai/codex-linux-arm64",
+ "@openai/codex-darwin-x64",
+ "@openai/codex-darwin-arm64",
+ "@openai/codex-win32-x64",
+ "@openai/codex-win32-arm64",
+ ]);
});
});
diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts
index 9261f62cb970..ae1d030f519a 100644
--- a/src/infra/npm-managed-root.test.ts
+++ b/src/infra/npm-managed-root.test.ts
@@ -9,6 +9,7 @@ import type { CommandOptions } from "../process/exec.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { captureEnv } from "../test-utils/env.js";
import {
+ listMissingRequiredPlatformPackages,
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
readManagedNpmRootInstalledDependency,
@@ -99,6 +100,67 @@ function requireCommandOptions(
}
describe("managed npm root", () => {
+ it("finds explicitly required optional packages for the current platform", async () => {
+ const npmRoot = await makeTempRoot();
+ const matchingPackage = "@vendor/tool-platform";
+ const scriptedPackage = "@vendor/tool-scripted";
+ const foreignPackage = "@vendor/tool-foreign";
+ const unconstrainedPackage = "@vendor/tool-optional";
+ const unlistedPackage = "@vendor/tool-unlisted";
+ await fs.writeFile(
+ path.join(npmRoot, "package-lock.json"),
+ `${JSON.stringify({
+ lockfileVersion: 3,
+ packages: {
+ "": {},
+ [`node_modules/${matchingPackage}`]: {
+ optional: true,
+ os: [process.platform],
+ cpu: [process.arch],
+ },
+ [`node_modules/${scriptedPackage}`]: {
+ optional: true,
+ hasInstallScript: true,
+ os: [process.platform],
+ cpu: [process.arch],
+ },
+ [`node_modules/${foreignPackage}`]: {
+ optional: true,
+ os: [`not-${process.platform}`],
+ cpu: [process.arch],
+ },
+ [`node_modules/${unconstrainedPackage}`]: {
+ optional: true,
+ },
+ [`node_modules/${unlistedPackage}`]: {
+ optional: true,
+ os: [process.platform],
+ cpu: [process.arch],
+ },
+ },
+ })}\n`,
+ );
+
+ await expect(
+ listMissingRequiredPlatformPackages({
+ npmRoot,
+ requiredPackageNames: [
+ matchingPackage,
+ scriptedPackage,
+ foreignPackage,
+ unconstrainedPackage,
+ ],
+ }),
+ ).resolves.toEqual(
+ [matchingPackage, scriptedPackage]
+ .map((name) => ({
+ name,
+ packagePath: path.join(npmRoot, "node_modules", ...name.split("/")),
+ }))
+ .toSorted((left, right) => left.packagePath.localeCompare(right.packagePath)),
+ );
+ });
+
it("keeps existing plugin dependencies when adding another managed plugin", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts
index e0f7350b9c51..2220851b3406 100644
--- a/src/infra/npm-managed-root.ts
+++ b/src/infra/npm-managed-root.ts
@@ -374,13 +374,11 @@ function isUnsupportedOptionalLockPackage(value: unknown): boolean {
);
}
-function readLockPackageName(location: string, value: unknown): string | undefined {
- if (isRecord(value)) {
- const packageName = readOptionalString(value.name);
- if (packageName) {
- return packageName;
- }
- }
+function hasNpmPlatformConstraint(value: Record): boolean {
+ return value.os !== undefined || value.cpu !== undefined || value.libc !== undefined;
+}
+
+function readLockPackageLocationName(location: string): string | undefined {
const parts = location.split("/");
for (let index = parts.length - 1; index >= 0; index -= 1) {
if (parts[index] !== "node_modules") {
@@ -399,10 +397,79 @@ function readLockPackageName(location: string, value: unknown): string | undefin
return undefined;
}
+function readLockPackageName(location: string, value: unknown): string | undefined {
+ if (isRecord(value)) {
+ const packageName = readOptionalString(value.name);
+ if (packageName) {
+ return packageName;
+ }
+ }
+ return readLockPackageLocationName(location);
+}
+
+function resolveManagedNpmLockPackagePath(params: {
+ npmRoot: string;
+ location: string;
+}): string | undefined {
+ const npmRoot = path.resolve(params.npmRoot);
+ const packagePath = path.resolve(npmRoot, ...params.location.split("/"));
+ const relativePath = path.relative(npmRoot, packagePath);
+ if (
+ !relativePath ||
+ relativePath === ".." ||
+ relativePath.startsWith(`..${path.sep}`) ||
+ path.isAbsolute(relativePath)
+ ) {
+ return undefined;
+ }
+ return packagePath;
+}
+
function isTopLevelLockPackageLocation(location: string): boolean {
return location.split("/").filter((part) => part === "node_modules").length === 1;
}
+export type MissingRequiredPlatformPackage = {
+ name: string;
+ packagePath: string;
+};
+
+/** Lists explicitly required current-platform packages that npm recorded but did not materialize. */
+export async function listMissingRequiredPlatformPackages(params: {
+ npmRoot: string;
+ requiredPackageNames: ReadonlySet | readonly string[];
+}): Promise {
+ const requiredPackageNames = new Set(params.requiredPackageNames);
+ if (requiredPackageNames.size === 0) {
+ return [];
+ }
+ const lockPath = path.join(params.npmRoot, "package-lock.json");
+ const parsed = await readJson(lockPath);
+ if (!isRecord(parsed) || !isRecord(parsed.packages)) {
+ return [];
+ }
+ const missing: MissingRequiredPlatformPackage[] = [];
+ for (const [location, value] of Object.entries(parsed.packages)) {
+ if (
+ !isRecord(value) ||
+ value.optional !== true ||
+ !hasNpmPlatformConstraint(value) ||
+ isUnsupportedOptionalLockPackage(value)
+ ) {
+ continue;
+ }
+ const name = readLockPackageLocationName(location);
+ const packagePath = resolveManagedNpmLockPackagePath({ npmRoot: params.npmRoot, location });
+ if (!name || !requiredPackageNames.has(name) || !isSafePackageName(name) || !packagePath) {
+ continue;
+ }
+ if (!(await pathExists(packagePath))) {
+ missing.push({ name, packagePath });
+ }
+ }
+ return missing.toSorted((left, right) => left.packagePath.localeCompare(right.packagePath));
+}
+
function findLockPackageVersion(params: {
lockfile: ManagedNpmRootLockfile;
packageName: string;
diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts
index 1f6eefc9b845..095f13a06528 100644
--- a/src/plugins/install.npm-spec.test.ts
+++ b/src/plugins/install.npm-spec.test.ts
@@ -287,6 +287,30 @@ function writeNpmRootPackageLock(params: {
);
}
+function writeMissingCurrentPlatformOptionalPackage(params: {
+ npmRoot: string;
+ packageName: string;
+ packageLocation: string;
+}): void {
+ const lockPath = path.join(params.npmRoot, "package-lock.json");
+ const lockfile = JSON.parse(fs.readFileSync(lockPath, "utf8")) as {
+ packages?: Record;
+ };
+ lockfile.packages ??= {};
+ lockfile.packages[params.packageLocation] = {
+ name: params.packageName,
+ version: "1.0.0-platform",
+ optional: true,
+ os: [process.platform],
+ cpu: [process.arch],
+ };
+ fs.writeFileSync(lockPath, `${JSON.stringify(lockfile, null, 2)}\n`, "utf8");
+ fs.rmSync(path.join(params.npmRoot, ...params.packageLocation.split("/")), {
+ recursive: true,
+ force: true,
+ });
+}
+
function readTextFileTree(dir: string, rootDir = dir): Record {
return Object.fromEntries(
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
@@ -950,6 +974,139 @@ describe("installPluginFromNpmSpec", () => {
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false);
});
+ it("repairs omitted current-platform packages with a fresh npm cache", async () => {
+ const stateDir = suiteTempRootTracker.makeTempDir();
+ const npmRoot = path.join(stateDir, "npm");
+ const packageName = "@openclaw/codex-fixture";
+ const platformPackage = "@vendor/codex-platform";
+ const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
+ const platformPackageLocation = path.posix.join(
+ "node_modules",
+ packageName,
+ "node_modules",
+ platformPackage,
+ );
+ const warnings: string[] = [];
+ mockNpmViewAndInstall({
+ spec: `${packageName}@1.0.0`,
+ packageName,
+ version: "1.0.0",
+ pluginId: "codex-fixture",
+ npmRoot,
+ expectedDependencySpec: "1.0.0",
+ openclaw: {
+ extensions: ["./dist/index.js"],
+ install: { requiredPlatformPackages: [platformPackage] },
+ },
+ });
+ const delegate = runCommandWithTimeoutMock.getMockImplementation();
+ if (!delegate) {
+ throw new Error("expected npm mock implementation");
+ }
+ let managedInstallAttempts = 0;
+ let repairCacheDir = "";
+ runCommandWithTimeoutMock.mockImplementation(
+ async (argv: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
+ const result = await delegate(argv, options);
+ if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
+ managedInstallAttempts += 1;
+ if (managedInstallAttempts === 1) {
+ writeMissingCurrentPlatformOptionalPackage({
+ npmRoot: npmProjectRoot,
+ packageName: platformPackage,
+ packageLocation: platformPackageLocation,
+ });
+ } else {
+ repairCacheDir = options.env?.npm_config_cache ?? "";
+ const packageDir = path.join(npmProjectRoot, ...platformPackageLocation.split("/"));
+ fs.mkdirSync(packageDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(packageDir, "package.json"),
+ JSON.stringify({ name: platformPackage, version: "1.0.0-platform" }),
+ "utf8",
+ );
+ }
+ }
+ return result;
+ },
+ );
+
+ const result = await installPluginFromNpmSpec({
+ spec: `${packageName}@1.0.0`,
+ npmDir: npmRoot,
+ logger: { info: () => {}, warn: (message) => warnings.push(message) },
+ });
+
+ expect(result.ok).toBe(true);
+ expect(managedInstallAttempts).toBe(2);
+ expect(repairCacheDir).toContain("openclaw-npm-cache-");
+ expect(fs.existsSync(repairCacheDir)).toBe(false);
+ expect(warnings).toContain(
+ `npm omitted current-platform package(s) ${platformPackage}; retrying once with a fresh cache.`,
+ );
+ });
+
+ it("rejects installs that still omit current-platform packages after repair", async () => {
+ const stateDir = suiteTempRootTracker.makeTempDir();
+ const npmRoot = path.join(stateDir, "npm");
+ const packageName = "@openclaw/codex-fixture";
+ const platformPackage = "@vendor/codex-platform";
+ const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
+ const platformPackageLocation = path.posix.join(
+ "node_modules",
+ packageName,
+ "node_modules",
+ platformPackage,
+ );
+ mockNpmViewAndInstall({
+ spec: `${packageName}@1.0.0`,
+ packageName,
+ version: "1.0.0",
+ pluginId: "codex-fixture",
+ npmRoot,
+ expectedDependencySpec: "1.0.0",
+ openclaw: {
+ extensions: ["./dist/index.js"],
+ install: { requiredPlatformPackages: [platformPackage] },
+ },
+ });
+ const delegate = runCommandWithTimeoutMock.getMockImplementation();
+ if (!delegate) {
+ throw new Error("expected npm mock implementation");
+ }
+ let managedInstallAttempts = 0;
+ runCommandWithTimeoutMock.mockImplementation(
+ async (argv: string[], options?: { cwd?: string }) => {
+ const result = await delegate(argv, options);
+ if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
+ managedInstallAttempts += 1;
+ writeMissingCurrentPlatformOptionalPackage({
+ npmRoot: npmProjectRoot,
+ packageName: platformPackage,
+ packageLocation: platformPackageLocation,
+ });
+ }
+ return result;
+ },
+ );
+
+ const result = await installPluginFromNpmSpec({
+ spec: `${packageName}@1.0.0`,
+ npmDir: npmRoot,
+ logger: { info: () => {}, warn: () => {} },
+ });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) {
+ return;
+ }
+ expect(managedInstallAttempts).toBe(2);
+ expect(result.error).toContain(
+ `npm install reported success but omitted required current-platform package(s): ${platformPackage}`,
+ );
+ expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false);
+ });
+
it("quarantines and rebuilds a corrupt managed npm project after npm from-argument failures", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
diff --git a/src/plugins/install.ts b/src/plugins/install.ts
index 983e54f6aac0..9fe19f8bb271 100644
--- a/src/plugins/install.ts
+++ b/src/plugins/install.ts
@@ -18,6 +18,7 @@ import {
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
import {
type ManagedNpmRootPeerDependencySnapshot,
+ listMissingRequiredPlatformPackages,
readManagedNpmRootInstalledDependency,
readManagedNpmRootPeerDependencySnapshot,
readOpenClawManagedNpmRootOverrides,
@@ -1070,6 +1071,41 @@ function resolveManagedNpmRootPackageDir(npmRoot: string, packageName: string):
return path.join(npmRoot, "node_modules", ...packageName.split("/"));
}
+function resolveRequiredPlatformPackageNames(
+ packageMetadata?: OpenClawPackageManifest,
+): { ok: true; packageNames: string[] } | { ok: false; error: string } {
+ const raw = packageMetadata?.install?.requiredPlatformPackages as unknown;
+ if (raw === undefined) {
+ return { ok: true, packageNames: [] };
+ }
+ if (!Array.isArray(raw)) {
+ return {
+ ok: false,
+ error: "package.json openclaw.install.requiredPlatformPackages must be an array",
+ };
+ }
+ const packageNames = new Set();
+ for (const value of raw) {
+ if (typeof value !== "string") {
+ return {
+ ok: false,
+ error:
+ "package.json openclaw.install.requiredPlatformPackages must contain only npm package names",
+ };
+ }
+ const specError = validateRegistryNpmSpec(value);
+ const parsed = parseRegistryNpmSpec(value);
+ if (specError || !parsed || parsed.selectorKind !== "none") {
+ return {
+ ok: false,
+ error: `package.json openclaw.install.requiredPlatformPackages contains invalid package name: ${value}`,
+ };
+ }
+ packageNames.add(parsed.name);
+ }
+ return { ok: true, packageNames: [...packageNames] };
+}
+
async function listNewManagedNpmRootPackageDirs(params: {
beforeInstallPackageNames: Set;
npmRoot: string;
@@ -1407,6 +1443,93 @@ async function installPluginFromManagedNpmRoot(
"npm install could not settle managed peer dependencies after 10 sync passes; refusing to leave a partially reconciled plugin dependency tree.",
});
}
+ const packageManifestResult = await readOptionalPackageManifest({
+ runtime,
+ packageDir: installRoot,
+ });
+ if (!packageManifestResult.ok) {
+ return await rollbackFailedManagedNpmInstall(packageManifestResult);
+ }
+ const requiredPlatformPackageNames = resolveRequiredPlatformPackageNames(
+ packageManifestResult.manifest
+ ? runtime.getPackageManifestMetadata(packageManifestResult.manifest)
+ : undefined,
+ );
+ if (!requiredPlatformPackageNames.ok) {
+ return await rollbackFailedManagedNpmInstall({
+ ok: false,
+ error: requiredPlatformPackageNames.error,
+ });
+ }
+ let omittedPlatformPackages: Awaited>;
+ try {
+ omittedPlatformPackages = await listMissingRequiredPlatformPackages({
+ npmRoot,
+ requiredPackageNames: requiredPlatformPackageNames.packageNames,
+ });
+ } catch (error) {
+ return await rollbackFailedManagedNpmInstall({
+ ok: false,
+ error: `Failed to verify platform-specific npm dependencies for ${params.packageName}: ${String(error)}`,
+ });
+ }
+ if (omittedPlatformPackages.length > 0) {
+ const omittedPlatformPackageNames = omittedPlatformPackages.map((entry) => entry.name);
+ logger.warn?.(
+ `npm omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}; retrying once with a fresh cache.`,
+ );
+ let freshCacheDir: string | undefined;
+ try {
+ freshCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-cache-"));
+ install = await runCommandWithTimeout(npmInstallArgs, {
+ ...npmInstallOptions,
+ env: {
+ ...npmInstallOptions.env,
+ NPM_CONFIG_CACHE: freshCacheDir,
+ npm_config_cache: freshCacheDir,
+ },
+ });
+ } catch (error) {
+ return await rollbackFailedManagedNpmInstall({
+ ok: false,
+ error: `Failed to repair omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}: ${String(error)}`,
+ });
+ } finally {
+ if (freshCacheDir) {
+ try {
+ await fs.rm(freshCacheDir, { recursive: true, force: true });
+ } catch (error) {
+ logger.warn?.(
+ `Failed to remove temporary npm cache ${freshCacheDir}: ${String(error)}`,
+ );
+ }
+ }
+ }
+ if (install.code !== 0) {
+ return await rollbackFailedManagedNpmInstall({
+ ok: false,
+ error: `npm install failed while repairing omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}: ${formatNpmCommandFailureOutput(install)}`,
+ });
+ }
+ let stillOmittedPlatformPackages: typeof omittedPlatformPackages;
+ try {
+ stillOmittedPlatformPackages = await listMissingRequiredPlatformPackages({
+ npmRoot,
+ requiredPackageNames: requiredPlatformPackageNames.packageNames,
+ });
+ } catch (error) {
+ return await rollbackFailedManagedNpmInstall({
+ ok: false,
+ error: `Failed to verify repaired platform-specific npm dependencies for ${params.packageName}: ${String(error)}`,
+ });
+ }
+ if (stillOmittedPlatformPackages.length > 0) {
+ return await rollbackFailedManagedNpmInstall({
+ ok: false,
+ error: `npm install reported success but omitted required current-platform package(s): ${stillOmittedPlatformPackages.map((entry) => entry.name).join(", ")}`,
+ });
+ }
+ }
if (params.packageName !== "openclaw") {
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
npmRoot,
diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts
index 4e8aa36c20d1..ebec5fbbdd60 100644
--- a/src/plugins/manifest.ts
+++ b/src/plugins/manifest.ts
@@ -1956,6 +1956,7 @@ export type PluginPackageInstall = {
minHostVersion?: string;
expectedIntegrity?: string;
allowInvalidConfigRecovery?: boolean;
+ requiredPlatformPackages?: string[];
};
export type OpenClawPackageStartup = {
From 59950f7b52667ed8572bd0fb3de6fb7d6cbabbaf Mon Sep 17 00:00:00 2001
From: "openclaw-clownfish[bot]"
<280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Date: Tue, 16 Jun 2026 14:03:13 +0800
Subject: [PATCH 04/24] fix(ui): preserve gateway token during safe websocket
url edits (#73923)
* fix(ui): preserve gateway token during safe websocket url edits
* fix(ui): preserve gateway token during safe websocket url edits
---------
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
---
CHANGELOG.md | 1 +
ui/src/ui/navigation.browser.test.ts | 24 +++++++++-
ui/src/ui/storage.ts | 15 ++++++
ui/src/ui/views/overview.node.test.ts | 68 ++++++++++++++++++++++++++-
ui/src/ui/views/overview.ts | 8 +++-
5 files changed, 111 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e87a1fc77d7..e37dd594c108 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
+- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts
index bcdbd4106510..22c86ae7e896 100644
--- a/ui/src/ui/navigation.browser.test.ts
+++ b/ui/src/ui/navigation.browser.test.ts
@@ -774,7 +774,7 @@ describe("control UI routing", () => {
expect(thread.scrollTop).toBe(targetScrollTop);
});
- it("hydrates hash tokens, restores same-tab refreshes, and clears after gateway changes", async () => {
+ it("hydrates hash tokens, preserves same-scope URL edits, and reloads after gateway changes", async () => {
const app = mountApp("/ui/overview#token=abc123");
await app.updateComplete;
@@ -799,12 +799,32 @@ describe("control UI routing", () => {
'input[placeholder="ws://100.x.y.z:18789"]',
HTMLInputElement,
);
+
+ const sameScopeUrl = `${refreshed.settings.gatewayUrl}/`;
+ gatewayUrlInput.value = sameScopeUrl;
+ gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await refreshed.updateComplete;
+
+ expect(refreshed.settings.gatewayUrl).toBe(sameScopeUrl);
+ expect(refreshed.settings.token).toBe("abc123");
+
+ gatewayUrlInput.value = "wss://missing-token.example/openclaw";
+ gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await refreshed.updateComplete;
+
+ expect(refreshed.settings.gatewayUrl).toBe("wss://missing-token.example/openclaw");
+ expect(refreshed.settings.token).toBe("");
+
+ sessionStorage.setItem(
+ "openclaw.control.token.v1:wss://other-gateway.example/openclaw",
+ "other-token",
+ );
gatewayUrlInput.value = "wss://other-gateway.example/openclaw";
gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
await refreshed.updateComplete;
expect(refreshed.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
- expect(refreshed.settings.token).toBe("");
+ expect(refreshed.settings.token).toBe("other-token");
});
it("keeps a hash token pending until the gateway URL change is confirmed", async () => {
diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts
index f6f333587a2b..4b0bcf2857fe 100644
--- a/ui/src/ui/storage.ts
+++ b/ui/src/ui/storage.ts
@@ -199,6 +199,21 @@ function loadSessionToken(gatewayUrl: string): string {
}
}
+export function resolveGatewayTokenForUrlEdit(
+ currentGatewayUrl: string,
+ nextGatewayUrl: string,
+ currentToken: string,
+): string {
+ if (
+ normalizeGatewayTokenScope(currentGatewayUrl) === normalizeGatewayTokenScope(nextGatewayUrl)
+ ) {
+ return currentToken;
+ }
+ // Gateway tokens stay session-scoped across endpoint edits.
+ // Durable settings may contain scrubbed legacy tokens, but must not restore them here.
+ return loadSessionToken(nextGatewayUrl);
+}
+
function persistSessionToken(gatewayUrl: string, token: string) {
try {
const storage = getSessionStorage();
diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts
index 7f1ee3a67f5b..ad9925220626 100644
--- a/ui/src/ui/views/overview.node.test.ts
+++ b/ui/src/ui/views/overview.node.test.ts
@@ -1,6 +1,8 @@
// @vitest-environment node
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
+import { createStorageMock } from "../../test-helpers/storage.ts";
+import { resolveGatewayTokenForUrlEdit } from "../storage.ts";
import {
resolveAuthHintKind,
resolvePairingHint,
@@ -8,6 +10,70 @@ import {
shouldShowPairingHint,
} from "./overview-hints.ts";
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("resolveGatewayTokenForUrlEdit", () => {
+ it("preserves the current token for same normalized gateway endpoint edits", () => {
+ expect(
+ resolveGatewayTokenForUrlEdit(
+ "wss://gateway.example/openclaw",
+ " wss://gateway.example/openclaw/ ",
+ "abc123",
+ ),
+ ).toBe("abc123");
+ });
+
+ it("loads a scoped token when the normalized gateway endpoint changes", () => {
+ vi.stubGlobal("sessionStorage", createStorageMock());
+ sessionStorage.setItem(
+ "openclaw.control.token.v1:wss://other-gateway.example/openclaw",
+ "other-token",
+ );
+
+ expect(
+ resolveGatewayTokenForUrlEdit(
+ "wss://gateway.example/openclaw",
+ "wss://other-gateway.example/openclaw/",
+ "abc123",
+ ),
+ ).toBe("other-token");
+ });
+
+ it("clears the token when the changed gateway endpoint has no scoped token", () => {
+ vi.stubGlobal("sessionStorage", createStorageMock());
+
+ expect(
+ resolveGatewayTokenForUrlEdit(
+ "wss://gateway.example/openclaw",
+ "wss://other-gateway.example/openclaw",
+ "abc123",
+ ),
+ ).toBe("");
+ });
+
+ it("does not restore legacy durable tokens when the gateway endpoint changes", () => {
+ vi.stubGlobal("localStorage", createStorageMock());
+ vi.stubGlobal("sessionStorage", createStorageMock());
+ localStorage.setItem(
+ "openclaw.control.settings.v1",
+ JSON.stringify({
+ gatewayUrl: "wss://other-gateway.example/openclaw",
+ token: "legacy-durable-token",
+ }),
+ );
+
+ expect(
+ resolveGatewayTokenForUrlEdit(
+ "wss://gateway.example/openclaw",
+ "wss://other-gateway.example/openclaw",
+ "abc123",
+ ),
+ ).toBe("");
+ });
+});
+
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts
index 9335e97a9fbe..8d7daf670f13 100644
--- a/ui/src/ui/views/overview.ts
+++ b/ui/src/ui/views/overview.ts
@@ -6,7 +6,7 @@ import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.ts";
import { icons } from "../icons.ts";
-import type { UiSettings } from "../storage.ts";
+import { resolveGatewayTokenForUrlEdit, type UiSettings } from "../storage.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type {
AttentionItem,
@@ -269,7 +269,11 @@ export function renderOverview(props: OverviewProps) {
props.onSettingsChange({
...props.settings,
gatewayUrl: v,
- token: v.trim() === props.settings.gatewayUrl.trim() ? props.settings.token : "",
+ token: resolveGatewayTokenForUrlEdit(
+ props.settings.gatewayUrl,
+ v,
+ props.settings.token,
+ ),
});
}}
placeholder="ws://100.x.y.z:18789"
From 6aa83374d90d35226b9cf3577155c3876a91b4b3 Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 08:05:56 +0200
Subject: [PATCH 05/24] fix(scripts): pin Docker preflight platform
---
scripts/test-docker-all.mjs | 13 +++++++++++--
test/scripts/docker-all-scheduler.test.ts | 13 +++++++++++++
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs
index 77f3fa397483..1c4c6815a859 100644
--- a/scripts/test-docker-all.mjs
+++ b/scripts/test-docker-all.mjs
@@ -542,6 +542,15 @@ export function dockerPreflightContainerNames(raw) {
);
}
+export function resolveDockerPreflightPlatform(arch = process.arch) {
+ return arch === "arm64" ? "linux/arm64" : "linux/amd64";
+}
+
+export function dockerPreflightSmokeCommand(arch = process.arch) {
+ const platform = resolveDockerPreflightPlatform(arch);
+ return `docker run --rm --platform ${shellQuote(platform)} alpine:3.20 true`;
+}
+
export function runShellCommand({ command, env, label, logFile, timeoutMs, noOutputTimeoutMs }) {
return new Promise((resolve) => {
const pipeOutput = Boolean(logFile || noOutputTimeoutMs > 0);
@@ -873,7 +882,7 @@ async function runDockerPreflight(baseEnv, options) {
const startedAt = Date.now();
const run = await runShellCommand({
- command: "docker run --rm alpine:3.20 true",
+ command: dockerPreflightSmokeCommand(),
env: baseEnv,
label: "docker-run-smoke",
timeoutMs: options.runTimeoutMs,
@@ -881,7 +890,7 @@ async function runDockerPreflight(baseEnv, options) {
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
if (run.status !== 0) {
throw new Error(
- `Docker preflight failed: docker run alpine:3.20 true status=${run.status} elapsed=${elapsedSeconds}s`,
+ `Docker preflight failed: ${dockerPreflightSmokeCommand()} status=${run.status} elapsed=${elapsedSeconds}s`,
);
}
console.log(`==> Docker preflight run: ${elapsedSeconds}s`);
diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts
index 9147487580f0..770b64861dff 100644
--- a/test/scripts/docker-all-scheduler.test.ts
+++ b/test/scripts/docker-all-scheduler.test.ts
@@ -11,8 +11,10 @@ import {
canStartSchedulerLane,
describeDockerSchedulerLimits,
dockerPreflightContainerNames,
+ dockerPreflightSmokeCommand,
LOG_TAIL_MAX_BYTES,
parseDockerAllCliArgs,
+ resolveDockerPreflightPlatform,
runShellCommand,
SHELL_CAPTURE_MAX_CHARS,
tailFile,
@@ -417,6 +419,17 @@ postgres Created
]);
});
+ it("pins Docker preflight smoke to the native platform", () => {
+ expect(resolveDockerPreflightPlatform("x64")).toBe("linux/amd64");
+ expect(resolveDockerPreflightPlatform("arm64")).toBe("linux/arm64");
+ expect(dockerPreflightSmokeCommand("x64")).toBe(
+ "docker run --rm --platform 'linux/amd64' alpine:3.20 true",
+ );
+ expect(dockerPreflightSmokeCommand("arm64")).toBe(
+ "docker run --rm --platform 'linux/arm64' alpine:3.20 true",
+ );
+ });
+
it("bounds captured preflight command output while keeping the newest tail", () => {
const first = appendBoundedShellCapture("abc", "def", 8);
expect(first).toEqual({ text: "abcdef", truncated: false });
From 2196ea29304a2c79efd30fcfcafa6a8391667888 Mon Sep 17 00:00:00 2001
From: zhang-guiping
Date: Tue, 16 Jun 2026 14:17:37 +0800
Subject: [PATCH 06/24] fix #85871: [Bug]: Heartbeat scheduler silently fails
to fire on 5.20 and all 5.x versions (regression from 4.23) (#88970)
* fix heartbeat deferral during active embedded runs
* fix heartbeat admission busy retry
* fix(heartbeat): bind retry to local admission
---------
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
---
.../agent-runner.runreplyagent.e2e.test.ts | 33 +++++++++++--
src/auto-reply/reply/agent-runner.ts | 14 ++++++
.../reply/reply-operation-run-state.ts | 21 ++++++++
...tbeat-runner.returns-default-unset.test.ts | 31 ++++++++++++
...eat-runner.skips-busy-session-lane.test.ts | 39 +++++++++++++++
src/infra/heartbeat-runner.ts | 49 ++++++++++++++++---
6 files changed, 176 insertions(+), 11 deletions(-)
create mode 100644 src/auto-reply/reply/reply-operation-run-state.ts
diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
index abb4a3deadfb..b46e726e1ffa 100644
--- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
+++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
@@ -15,6 +15,11 @@ import {
type FollowupRun,
type QueueSettings,
} from "./queue.js";
+import {
+ REPLY_OPERATION_RUN_STATE,
+ type ReplyOperationRunState,
+ type ReplyOptionsWithOperationRunState,
+} from "./reply-operation-run-state.js";
import { createReplyOperation, testing as replyRunTesting } from "./reply-run-registry.js";
import { consumeReplyUsageState } from "./reply-usage-state.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -152,7 +157,7 @@ beforeEach(() => {
});
function createMinimalRun(params?: {
- opts?: GetReplyOptions;
+ opts?: GetReplyOptions & ReplyOptionsWithOperationRunState;
resolvedVerboseLevel?: "off" | "on";
sessionStore?: Record;
sessionEntry?: SessionEntry;
@@ -245,13 +250,14 @@ function createMinimalRun(params?: {
describe("runReplyAgent heartbeat followup guard", () => {
it("drops heartbeat runs when reply-lane admission finds an active owner", async () => {
+ const runState: ReplyOperationRunState = {};
const active = createReplyOperation({
sessionKey: "main",
sessionId: "active-session",
resetTriggered: false,
});
const { run, typing } = createMinimalRun({
- opts: { isHeartbeat: true },
+ opts: { isHeartbeat: true, [REPLY_OPERATION_RUN_STATE]: runState },
isActive: false,
shouldFollowup: false,
});
@@ -261,9 +267,21 @@ describe("runReplyAgent heartbeat followup guard", () => {
expect(result).toBeUndefined();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
+ expect(runState.admission).toEqual({ status: "skipped", reason: "active-run" });
active.complete();
});
+ it("records the operation owned by an admitted heartbeat run", async () => {
+ const runState: ReplyOperationRunState = {};
+ const { run } = createMinimalRun({
+ opts: { isHeartbeat: true, [REPLY_OPERATION_RUN_STATE]: runState },
+ });
+
+ await run();
+
+ expect(runState.admission).toEqual({ status: "owned" });
+ });
+
it("runs visible turns with the session id returned by admission", async () => {
const active = createReplyOperation({
sessionKey: "main",
@@ -315,8 +333,12 @@ describe("runReplyAgent heartbeat followup guard", () => {
it("drops runs when reply-lane admission sees an already-aborted caller", async () => {
const abortController = new AbortController();
abortController.abort();
+ const runState: ReplyOperationRunState = {};
const { run, typing } = createMinimalRun({
- opts: { abortSignal: abortController.signal },
+ opts: {
+ abortSignal: abortController.signal,
+ [REPLY_OPERATION_RUN_STATE]: runState,
+ },
isActive: false,
shouldFollowup: false,
});
@@ -326,11 +348,13 @@ describe("runReplyAgent heartbeat followup guard", () => {
expect(result).toBeUndefined();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
+ expect(runState.admission).toEqual({ status: "skipped", reason: "aborted" });
});
it("drops heartbeat runs when another run is active", async () => {
+ const runState: ReplyOperationRunState = {};
const { run, typing } = createMinimalRun({
- opts: { isHeartbeat: true },
+ opts: { isHeartbeat: true, [REPLY_OPERATION_RUN_STATE]: runState },
isActive: true,
shouldFollowup: true,
resolvedQueueMode: "collect",
@@ -342,6 +366,7 @@ describe("runReplyAgent heartbeat followup guard", () => {
expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
+ expect(runState.admission).toEqual({ status: "skipped", reason: "active-run" });
});
it("drops heartbeat runs before steering active streams", async () => {
diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts
index 89b78c637b52..639f7d9c44c0 100644
--- a/src/auto-reply/reply/agent-runner.ts
+++ b/src/auto-reply/reply/agent-runner.ts
@@ -118,6 +118,7 @@ import {
type QueueSettings,
} from "./queue.js";
import { createReplyMediaContext } from "./reply-media-paths.js";
+import { resolveReplyOperationRunState } from "./reply-operation-run-state.js";
import {
replyRunRegistry,
runAfterReplyOperationClear,
@@ -1202,6 +1203,7 @@ export async function runReplyAgent(params: {
const activeRunQueueMode = effectiveResetTriggered ? "interrupt" : resolvedQueue.mode;
const isHeartbeat = opts?.isHeartbeat === true;
+ const replyOperationRunState = resolveReplyOperationRunState(opts);
const traceAttributes = {
provider: followupRun.run.provider,
hasSessionKey: Boolean(sessionKey ?? followupRun.run.sessionKey),
@@ -1295,6 +1297,9 @@ export async function runReplyAgent(params: {
});
if (activeRunQueueAction === "drop") {
+ if (replyOperationRunState) {
+ replyOperationRunState.admission = { status: "skipped", reason: "active-run" };
+ }
typing.cleanup();
return undefined;
}
@@ -1405,6 +1410,9 @@ export async function runReplyAgent(params: {
let replyOperation: ReplyOperation;
if (providedReplyOperation) {
replyOperation = providedReplyOperation;
+ if (replyOperationRunState) {
+ replyOperationRunState.admission = { status: "owned" };
+ }
} else {
const replyTurnKind = resolveReplyTurnKind(opts);
const admission = await admitReplyTurn({
@@ -1415,6 +1423,12 @@ export async function runReplyAgent(params: {
routeThreadId: replyRouteThreadId,
upstreamAbortSignal: opts?.abortSignal,
});
+ if (replyOperationRunState) {
+ replyOperationRunState.admission =
+ admission.status === "owned"
+ ? { status: "owned" }
+ : { status: "skipped", reason: admission.reason };
+ }
if (admission.status === "skipped") {
typing.cleanup();
if (admission.reason !== "active-run" || replyTurnKind !== "visible") {
diff --git a/src/auto-reply/reply/reply-operation-run-state.ts b/src/auto-reply/reply/reply-operation-run-state.ts
new file mode 100644
index 000000000000..3a978166d66f
--- /dev/null
+++ b/src/auto-reply/reply/reply-operation-run-state.ts
@@ -0,0 +1,21 @@
+export type ReplyOperationAdmissionSnapshot =
+ | { status: "owned" }
+ | { status: "skipped"; reason: "active-run" | "aborted" };
+
+export type ReplyOperationRunState = {
+ admission?: ReplyOperationAdmissionSnapshot;
+};
+
+// Carries this invocation's admission decision through reply option spreads so
+// heartbeat cleanup never infers it from whichever operation is active later.
+export const REPLY_OPERATION_RUN_STATE = Symbol("openclaw.replyOperationRunState");
+
+export type ReplyOptionsWithOperationRunState = {
+ [REPLY_OPERATION_RUN_STATE]?: ReplyOperationRunState;
+};
+
+export function resolveReplyOperationRunState(
+ options: object | undefined,
+): ReplyOperationRunState | undefined {
+ return (options as ReplyOptionsWithOperationRunState | undefined)?.[REPLY_OPERATION_RUN_STATE];
+}
diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts
index a11937c3d6eb..9341fe5569b8 100644
--- a/src/infra/heartbeat-runner.returns-default-unset.test.ts
+++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts
@@ -706,6 +706,7 @@ describe("runHeartbeatOnce", () => {
options?: {
nowMs?: number;
getReplyFromConfig?: HeartbeatDeps["getReplyFromConfig"];
+ listActiveEmbeddedRunSessionKeys?: HeartbeatDeps["listActiveEmbeddedRunSessionKeys"];
},
): HeartbeatDeps => ({
whatsapp: sendWhatsApp,
@@ -714,6 +715,9 @@ describe("runHeartbeatOnce", () => {
webAuthExists: async () => true,
hasActiveWebListener: () => true,
...(options?.getReplyFromConfig ? { getReplyFromConfig: options.getReplyFromConfig } : null),
+ ...(options?.listActiveEmbeddedRunSessionKeys
+ ? { listActiveEmbeddedRunSessionKeys: options.listActiveEmbeddedRunSessionKeys }
+ : null),
});
it("skips when agent heartbeat is not enabled", async () => {
@@ -731,6 +735,33 @@ describe("runHeartbeatOnce", () => {
}
});
+ it.each([
+ ["the heartbeat main session", (cfg: OpenClawConfig) => resolveMainSessionKey(cfg)],
+ ["another session for the same agent", () => "agent:main:telegram:alerts"],
+ ])("retries instead of dispatching while %s has an embedded run", async (_name, activeKey) => {
+ const cfg: OpenClawConfig = {
+ agents: {
+ defaults: {
+ heartbeat: { every: "5m", target: "none" },
+ },
+ },
+ };
+ const replySpy = vi.fn().mockResolvedValue({ text: "heartbeat reply" });
+ const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
+
+ const res = await runHeartbeatOnce({
+ cfg,
+ deps: createHeartbeatDeps(sendWhatsApp, {
+ getReplyFromConfig: replySpy,
+ listActiveEmbeddedRunSessionKeys: () => [activeKey(cfg)],
+ }),
+ });
+
+ expect(res).toEqual({ status: "skipped", reason: "requests-in-flight" });
+ expect(replySpy).not.toHaveBeenCalled();
+ expect(sendWhatsApp).not.toHaveBeenCalled();
+ });
+
it("skips outside active hours", async () => {
const cfg: OpenClawConfig = {
agents: {
diff --git a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts
index d1067c0d22e4..553a1f281fe6 100644
--- a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts
+++ b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts
@@ -1,6 +1,7 @@
// Covers heartbeat skipping while session lanes or cron jobs are busy.
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveNestedAgentLaneForSession } from "../agents/lanes.js";
+import { resolveReplyOperationRunState } from "../auto-reply/reply/reply-operation-run-state.js";
import {
__testing as replyRunRegistryTesting,
createReplyOperation,
@@ -360,6 +361,44 @@ describe("heartbeat runner skips when target session lane is busy", () => {
});
});
+ it("does not infer admission rejection from a replacement run after an empty heartbeat", async () => {
+ await withTempHeartbeatSandbox(async ({ storePath }) => {
+ const cfg = createHeartbeatTelegramConfig();
+ const sessionKey = await seedHeartbeatTelegramSession(storePath, cfg);
+ let operation: ReturnType | undefined;
+ const replySpy = vi.fn(async (_ctx, replyOptions) => {
+ const runState = resolveReplyOperationRunState(replyOptions);
+ if (!runState) {
+ throw new Error("expected heartbeat reply operation state");
+ }
+ runState.admission = { status: "owned" };
+ operation = createReplyOperation({
+ sessionKey,
+ sessionId: "racing-visible-session",
+ resetTriggered: false,
+ });
+ operation.setPhase("running");
+ return undefined;
+ });
+
+ try {
+ const result = await runHeartbeatOnce({
+ cfg,
+ deps: {
+ getQueueSize: vi.fn((_lane?: string) => 0),
+ nowMs: () => Date.now(),
+ getReplyFromConfig: replySpy,
+ } as HeartbeatDeps,
+ });
+
+ expect(result.status).toBe("ran");
+ expect(replySpy).toHaveBeenCalledOnce();
+ } finally {
+ operation?.complete();
+ }
+ });
+ });
+
it("returns requests-in-flight when an isolated heartbeat reply run is still active", async () => {
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
const cfg = createHeartbeatTelegramConfig();
diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts
index 5057881de09c..76e5a364eeb6 100644
--- a/src/infra/heartbeat-runner.ts
+++ b/src/infra/heartbeat-runner.ts
@@ -20,6 +20,7 @@ import {
} from "../agents/agent-scope.js";
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js";
+import { listActiveEmbeddedRunSessionKeys } from "../agents/embedded-agent-runner/run-state.js";
import { formatReasoningMessage } from "../agents/embedded-agent-utils.js";
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js";
@@ -43,6 +44,10 @@ import {
} from "../auto-reply/heartbeat.js";
import { replaceGenericExternalRunFailureText } from "../auto-reply/reply/agent-runner-failure-copy.js";
import { resolveDefaultModel } from "../auto-reply/reply/directive-handling.defaults.js";
+import {
+ REPLY_OPERATION_RUN_STATE,
+ type ReplyOperationRunState,
+} from "../auto-reply/reply/reply-operation-run-state.js";
import {
listActiveReplyRunSessionKeys,
replyRunRegistry,
@@ -155,6 +160,7 @@ export type HeartbeatDeps = OutboundSendDeps &
getCommandLaneSnapshots?: () => readonly CommandLaneSnapshot[];
isReplyRunActive?: (sessionKey: string) => boolean;
listActiveReplyRunSessionKeys?: () => readonly string[];
+ listActiveEmbeddedRunSessionKeys?: () => readonly string[];
nowMs?: () => number;
};
@@ -229,10 +235,7 @@ function hasAgentOptInBusyLaneWork(
return hasQueuedWorkInLaneSnapshots(getSnapshots(), (lane) => laneBelongsToAgent(lane, agentId));
}
-function hasActiveReplyRunForAgent(
- agentId: string,
- listSessionKeys: () => readonly string[],
-): boolean {
+function hasActiveRunForAgent(agentId: string, listSessionKeys: () => readonly string[]): boolean {
const normalizedAgentId = normalizeAgentId(agentId);
return listSessionKeys().some((sessionKey) => {
const parsed = parseAgentSessionKey(sessionKey);
@@ -240,6 +243,14 @@ function hasActiveReplyRunForAgent(
});
}
+function hasActiveRunForSession(
+ sessionKey: string,
+ listSessionKeys: () => readonly string[],
+): boolean {
+ const normalizedSessionKey = sessionKey.trim();
+ return Boolean(normalizedSessionKey) && listSessionKeys().includes(normalizedSessionKey);
+}
+
function resolveHeartbeatChannelPlugin(channel: string): ChannelPlugin | undefined {
const activePlugin = getActivePluginChannelRegistry()?.channels.find(
(entry) => entry.plugin.id === channel,
@@ -1358,10 +1369,16 @@ export async function runHeartbeatOnce(opts: {
const shouldHonorActiveReplyRuns = opts.intent !== "immediate" && opts.intent !== "manual";
const listActiveReplyRuns =
opts.deps?.listActiveReplyRunSessionKeys ?? listActiveReplyRunSessionKeys;
+ const listActiveEmbeddedRuns =
+ opts.deps?.listActiveEmbeddedRunSessionKeys ?? listActiveEmbeddedRunSessionKeys;
// Scheduled heartbeats are background work, so defer them when any session on
// the same agent is already replying; immediate/manual wakes keep their
// existing semantics for explicit user/system actions.
- if (shouldHonorActiveReplyRuns && hasActiveReplyRunForAgent(agentId, listActiveReplyRuns)) {
+ if (
+ shouldHonorActiveReplyRuns &&
+ (hasActiveRunForAgent(agentId, listActiveReplyRuns) ||
+ hasActiveRunForAgent(agentId, listActiveEmbeddedRuns))
+ ) {
emitHeartbeatEvent({
status: "skipped",
reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
@@ -1417,7 +1434,7 @@ export async function runHeartbeatOnce(opts: {
const { entry, sessionKey, storePath, suppressOriginatingContext } = preflight.session;
const isReplyRunActive =
opts.deps?.isReplyRunActive ?? ((key: string) => replyRunRegistry.isActive(key));
- if (isReplyRunActive(sessionKey)) {
+ if (isReplyRunActive(sessionKey) || hasActiveRunForSession(sessionKey, listActiveEmbeddedRuns)) {
emitHeartbeatEvent({
status: "skipped",
reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
@@ -1576,7 +1593,10 @@ export async function runHeartbeatOnce(opts: {
isolatedSessionKey,
isolatedBaseSessionKey,
});
- if (isReplyRunActive(isolatedSessionKey)) {
+ if (
+ isReplyRunActive(isolatedSessionKey) ||
+ hasActiveRunForSession(isolatedSessionKey, listActiveEmbeddedRuns)
+ ) {
emitHeartbeatEvent({
status: "skipped",
reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
@@ -1781,8 +1801,10 @@ export async function runHeartbeatOnce(opts: {
const timeoutOverrideSeconds = resolveHeartbeatTimeoutOverrideSeconds(cfg, heartbeat);
const bootstrapContextMode: "lightweight" | undefined =
heartbeat?.lightContext === true ? "lightweight" : undefined;
+ const replyOperationRunState: ReplyOperationRunState = {};
const replyOpts = {
isHeartbeat: true,
+ [REPLY_OPERATION_RUN_STATE]: replyOperationRunState,
...(heartbeatModelOverride ? { heartbeatModelOverride } : {}),
suppressToolErrorWarnings,
...(usesHeartbeatResponseTool ? { enableHeartbeatTool: true, forceHeartbeatTool: true } : {}),
@@ -1800,6 +1822,19 @@ export async function runHeartbeatOnce(opts: {
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
const heartbeatToolResponse = resolveHeartbeatToolResponseFromReplyResult(replyResult);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
+ if (
+ !heartbeatToolResponse &&
+ (!replyPayload || !hasOutboundReplyContent(replyPayload)) &&
+ replyOperationRunState.admission?.status === "skipped" &&
+ replyOperationRunState.admission.reason === "active-run"
+ ) {
+ emitHeartbeatEvent({
+ status: "skipped",
+ reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
+ durationMs: Date.now() - startedAt,
+ });
+ return { status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT };
+ }
const includeReasoning = heartbeat?.includeReasoning === true;
const reasoningPayloads = includeReasoning
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
From 4a0e376d1f8818344e393bbd2001766bb3868c49 Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:17:42 +0800
Subject: [PATCH 07/24] fix(imessage): normalize leading NUL echo-cache
prefixes (#93511)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: jason <3031622+drvoss@users.noreply.github.com>
---
CHANGELOG.md | 1 +
extensions/imessage/src/monitor/echo-cache.ts | 23 +++++++++++++-----
.../monitor-provider.echo-cache.test.ts | 24 +++++++++++++++++++
3 files changed, 42 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e37dd594c108..4514cb53e11c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
+- iMessage: normalize leading NUL sent-message echo prefixes while preserving interior NUL bytes and the leading attributedBody marker handling from #73942. Carries forward #63581. Thanks @drvoss.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
diff --git a/extensions/imessage/src/monitor/echo-cache.ts b/extensions/imessage/src/monitor/echo-cache.ts
index b99f4d7a2bbd..c7d3b1ee564a 100644
--- a/extensions/imessage/src/monitor/echo-cache.ts
+++ b/extensions/imessage/src/monitor/echo-cache.ts
@@ -34,17 +34,28 @@ export type SentMessageCache = {
// duplicate delivery (noisy but not lossy) — never message loss.
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
const SENT_MESSAGE_ID_TTL_MS = 60_000;
-const LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS = /^[\uFEFF\uFFFD\uFFFE\uFFFF]+/u;
+
+function isLeadingEchoTextCorruptionMarker(code: number): boolean {
+ return (
+ code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
+ );
+}
+
+function stripLeadingEchoTextCorruptionMarkers(text: string): string {
+ let offset = 0;
+ while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
+ offset += 1;
+ }
+ return offset === 0 ? text : text.slice(offset);
+}
function normalizeEchoTextKey(text: string | undefined): string | null {
if (!text) {
return null;
}
- const normalized = text
- .replace(/\r\n?/g, "\n")
- .trim()
- .replace(LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS, "")
- .trim();
+ const normalized = stripLeadingEchoTextCorruptionMarkers(
+ text.replace(/\r\n?/g, "\n").trim(),
+ ).trim();
return normalized ? normalized : null;
}
diff --git a/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts
index 910ac5a2a828..34ebad1881f9 100644
--- a/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts
+++ b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts
@@ -51,6 +51,20 @@ describe("iMessage sent-message echo cache", () => {
).toBe(true);
});
+ it("matches delayed reflected echoes with leading NUL corruption markers", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
+ const cache = createSentMessageCache();
+
+ cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
+
+ expect(
+ cache.has("acct:imessage:+1555", {
+ text: "\u0000\u0000Delayed echo reply",
+ }),
+ ).toBe(true);
+ });
+
it("keeps attributedBody corruption cleanup leading-only", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
@@ -67,6 +81,16 @@ describe("iMessage sent-message echo cache", () => {
expect(cache.has("acct:imessage:+1555", { text: "Delayed\necho reply" })).toBe(false);
});
+ it("keeps NUL corruption cleanup leading-only", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
+ const cache = createSentMessageCache();
+
+ cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
+
+ expect(cache.has("acct:imessage:+1555", { text: "Delayed\u0000echo reply" })).toBe(false);
+ });
+
it("matches by outbound message id and ignores placeholder ids", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
From bb164384c2d928ca263286af2b1155d4bdfae0c6 Mon Sep 17 00:00:00 2001
From: zhang-guiping
Date: Tue, 16 Jun 2026 14:17:57 +0800
Subject: [PATCH 08/24] [Bug]: ollama-cloud runtime fails DNS lookup for
ai.ollama.com, while ollama/:cloud works (#92594)
* fix(ollama): repair retired cloud provider endpoint
Route configured Ollama Cloud provider ids through plugin doctor compatibility migrations so doctor --fix can rewrite the retired ai.ollama.com endpoint before runtime reads persisted config.
Co-Authored-By: Claude Opus 4.7
* test(doctor): align provider fixture with typed config
Ensure the doctor registry provider-scoped migration test uses a fully typed provider fixture so the test type-check shard validates the intended behavior.
Co-Authored-By: Claude Opus 4.7
* test(ollama): align doctor fixture with typed config
Use fully typed provider and model fixtures in the Ollama doctor contract tests so the extension test type-check shard validates the migration behavior.
Co-Authored-By: Claude Opus 4.7
* fix(ollama): preserve custom cloud provider base url
Co-Authored-By: Claude Opus 4.7
* fix(ollama): avoid logging retired endpoint secrets
---------
Co-authored-by: Claude Opus 4.7
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
---
extensions/ollama/doctor-contract-api.test.ts | 182 ++++++++++++++++++
extensions/ollama/doctor-contract-api.ts | 1 +
extensions/ollama/src/config-compat.ts | 103 ++++++++++
src/plugins/doctor-contract-registry.test.ts | 86 ++++++++-
src/plugins/doctor-contract-registry.ts | 14 ++
5 files changed, 385 insertions(+), 1 deletion(-)
create mode 100644 extensions/ollama/doctor-contract-api.test.ts
create mode 100644 extensions/ollama/doctor-contract-api.ts
create mode 100644 extensions/ollama/src/config-compat.ts
diff --git a/extensions/ollama/doctor-contract-api.test.ts b/extensions/ollama/doctor-contract-api.test.ts
new file mode 100644
index 000000000000..f83a26697dc6
--- /dev/null
+++ b/extensions/ollama/doctor-contract-api.test.ts
@@ -0,0 +1,182 @@
+// Ollama tests cover doctor contract config compatibility.
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
+import { describe, expect, it } from "vitest";
+import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
+
+type ModelDefinition = NonNullable<
+ NonNullable["providers"]
+>[string]["models"][number];
+
+const cloudModel: ModelDefinition = {
+ id: "kimi-k2.5:cloud",
+ name: "Kimi K2.5 Cloud",
+ reasoning: false,
+ input: ["text"],
+ cost: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ },
+ contextWindow: 131072,
+ maxTokens: 8192,
+};
+
+function readOllamaCloudProvider(config: OpenClawConfig): Record | undefined {
+ return config.models?.providers?.["ollama-cloud"] as Record | undefined;
+}
+
+describe("ollama doctor contract", () => {
+ it("detects retired Ollama Cloud provider endpoints", () => {
+ expect(legacyConfigRules[0]?.match({ baseUrl: "https://ai.ollama.com" })).toBe(true);
+ expect(legacyConfigRules[0]?.match({ baseUrl: "https://ollama.com" })).toBe(false);
+ });
+
+ it("migrates retired Ollama Cloud provider baseUrl to the canonical endpoint", () => {
+ const config = {
+ models: {
+ providers: {
+ "ollama-cloud": {
+ baseUrl: "https://ai.ollama.com",
+ api: "ollama",
+ models: [cloudModel],
+ },
+ ollama: {
+ baseUrl: "http://127.0.0.1:11434",
+ api: "ollama",
+ models: [],
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const result = normalizeCompatibilityConfig({ cfg: config });
+
+ expect(result.changes).toEqual([
+ "Updated models.providers.ollama-cloud.baseUrl from the retired Ollama Cloud endpoint to https://ollama.com.",
+ ]);
+ expect(readOllamaCloudProvider(result.config)).toEqual({
+ baseUrl: "https://ollama.com",
+ api: "ollama",
+ models: [cloudModel],
+ });
+ expect(readOllamaCloudProvider(config)?.baseUrl).toBe("https://ai.ollama.com");
+ });
+
+ it("removes retired Ollama Cloud provider baseURL aliases when canonical baseUrl is present", () => {
+ const config = {
+ models: {
+ providers: {
+ "ollama-cloud": {
+ baseUrl: "https://ollama.com",
+ baseURL: "https://ai.ollama.com/",
+ api: "ollama",
+ models: [],
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const result = normalizeCompatibilityConfig({ cfg: config });
+
+ expect(result.changes).toEqual([
+ "Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
+ ]);
+ expect(readOllamaCloudProvider(result.config)).toEqual({
+ baseUrl: "https://ollama.com",
+ api: "ollama",
+ models: [],
+ });
+ expect(readOllamaCloudProvider(config)).toEqual({
+ baseUrl: "https://ollama.com",
+ baseURL: "https://ai.ollama.com/",
+ api: "ollama",
+ models: [],
+ });
+ });
+
+ it("migrates retired Ollama Cloud provider baseURL aliases when canonical baseUrl is blank", () => {
+ const config = {
+ models: {
+ providers: {
+ "ollama-cloud": {
+ baseUrl: " ",
+ baseURL: "https://ai.ollama.com/",
+ api: "ollama",
+ models: [],
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const result = normalizeCompatibilityConfig({ cfg: config });
+
+ expect(result.changes).toEqual([
+ "Updated models.providers.ollama-cloud.baseURL from the retired Ollama Cloud endpoint to https://ollama.com.",
+ ]);
+ expect(readOllamaCloudProvider(result.config)).toEqual({
+ baseUrl: "https://ollama.com",
+ api: "ollama",
+ models: [],
+ });
+ expect(readOllamaCloudProvider(config)).toEqual({
+ baseUrl: " ",
+ baseURL: "https://ai.ollama.com/",
+ api: "ollama",
+ models: [],
+ });
+ });
+
+ it("preserves custom canonical baseUrl when removing retired baseURL aliases", () => {
+ const config = {
+ models: {
+ providers: {
+ "ollama-cloud": {
+ baseUrl: "https://custom-ollama-cloud.example.test",
+ baseURL: "https://ai.ollama.com/",
+ api: "ollama",
+ models: [],
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const result = normalizeCompatibilityConfig({ cfg: config });
+
+ expect(result.changes).toEqual([
+ "Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
+ ]);
+ expect(readOllamaCloudProvider(result.config)).toEqual({
+ baseUrl: "https://custom-ollama-cloud.example.test",
+ api: "ollama",
+ models: [],
+ });
+ expect(readOllamaCloudProvider(config)).toEqual({
+ baseUrl: "https://custom-ollama-cloud.example.test",
+ baseURL: "https://ai.ollama.com/",
+ api: "ollama",
+ models: [],
+ });
+ });
+
+ it("does not expose credentials or query parameters from the retired URL", () => {
+ const config = {
+ models: {
+ providers: {
+ "ollama-cloud": {
+ baseUrl: "https://user:password@ai.ollama.com/?token=secret",
+ api: "ollama",
+ models: [],
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const result = normalizeCompatibilityConfig({ cfg: config });
+
+ expect(result.changes.join("\n")).not.toContain("user");
+ expect(result.changes.join("\n")).not.toContain("password");
+ expect(result.changes.join("\n")).not.toContain("secret");
+ expect(readOllamaCloudProvider(result.config)?.baseUrl).toBe("https://ollama.com");
+ });
+});
diff --git a/extensions/ollama/doctor-contract-api.ts b/extensions/ollama/doctor-contract-api.ts
new file mode 100644
index 000000000000..db610ee157d7
--- /dev/null
+++ b/extensions/ollama/doctor-contract-api.ts
@@ -0,0 +1 @@
+export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";
diff --git a/extensions/ollama/src/config-compat.ts b/extensions/ollama/src/config-compat.ts
new file mode 100644
index 000000000000..71b8e6d26040
--- /dev/null
+++ b/extensions/ollama/src/config-compat.ts
@@ -0,0 +1,103 @@
+// Ollama helper module supports config compat behavior.
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
+import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_PROVIDER_ID } from "./defaults.js";
+
+type LegacyConfigRule = {
+ path: Array;
+ message: string;
+ match: (value: unknown) => boolean;
+};
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function isRetiredOllamaCloudBaseUrl(value: unknown): value is string {
+ if (typeof value !== "string" || !value.trim()) {
+ return false;
+ }
+ try {
+ return new URL(value.trim()).hostname.toLowerCase() === "ai.ollama.com";
+ } catch {
+ return false;
+ }
+}
+
+function findRetiredOllamaCloudBaseUrl(provider: unknown): { key: "baseUrl" | "baseURL" } | null {
+ const record = asRecord(provider);
+ if (!record) {
+ return null;
+ }
+ if (isRetiredOllamaCloudBaseUrl(record.baseUrl)) {
+ return { key: "baseUrl" };
+ }
+ if (isRetiredOllamaCloudBaseUrl(record.baseURL)) {
+ return { key: "baseURL" };
+ }
+ return null;
+}
+
+export const legacyConfigRules: LegacyConfigRule[] = [
+ {
+ path: ["models", "providers", OLLAMA_CLOUD_PROVIDER_ID],
+ message:
+ 'models.providers.ollama-cloud.baseUrl="https://ai.ollama.com" is retired; use "https://ollama.com". Run "openclaw doctor --fix".',
+ match: (value) => findRetiredOllamaCloudBaseUrl(value) !== null,
+ },
+];
+
+export function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): {
+ config: OpenClawConfig;
+ changes: string[];
+} | null {
+ const provider = config.models?.providers?.[OLLAMA_CLOUD_PROVIDER_ID];
+ const retired = findRetiredOllamaCloudBaseUrl(provider);
+ if (!retired) {
+ return null;
+ }
+
+ const nextConfig = structuredClone(config);
+ const nextModels = asRecord(nextConfig.models) ?? {};
+ nextConfig.models = nextModels as OpenClawConfig["models"];
+ const nextProviders = asRecord(nextModels.providers) ?? {};
+ nextModels.providers = nextProviders;
+ const nextProvider = asRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {};
+ nextProviders[OLLAMA_CLOUD_PROVIDER_ID] = nextProvider;
+
+ const canonicalBaseUrl = nextProvider.baseUrl;
+ if (
+ retired.key === "baseURL" &&
+ typeof canonicalBaseUrl === "string" &&
+ canonicalBaseUrl.trim() &&
+ !isRetiredOllamaCloudBaseUrl(canonicalBaseUrl)
+ ) {
+ delete nextProvider.baseURL;
+ return {
+ config: nextConfig,
+ changes: [
+ "Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
+ ],
+ };
+ }
+
+ nextProvider.baseUrl = OLLAMA_CLOUD_BASE_URL;
+ if (retired.key === "baseURL") {
+ delete nextProvider.baseURL;
+ }
+
+ return {
+ config: nextConfig,
+ changes: [
+ `Updated models.providers.ollama-cloud.${retired.key} from the retired Ollama Cloud endpoint to ${OLLAMA_CLOUD_BASE_URL}.`,
+ ],
+ };
+}
+
+export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
+ config: OpenClawConfig;
+ changes: string[];
+} {
+ return migrateOllamaCloudRetiredBaseUrl(cfg) ?? { config: cfg, changes: [] };
+}
diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts
index cab978d49134..b7e8a83dd167 100644
--- a/src/plugins/doctor-contract-registry.test.ts
+++ b/src/plugins/doctor-contract-registry.test.ts
@@ -13,7 +13,9 @@ import {
const tempDirs: string[] = [];
const mocks = getRegistryJitiMocks();
+let applyPluginDoctorCompatibilityMigrations: typeof import("./doctor-contract-registry.js").applyPluginDoctorCompatibilityMigrations;
let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache;
+let collectRelevantDoctorPluginIds: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIds;
let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIdsForTouchedPaths;
let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
let listPluginDoctorSessionRouteStateOwners: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionRouteStateOwners;
@@ -43,7 +45,9 @@ describe("doctor-contract-registry module loader", () => {
resetRegistryJitiMocks();
vi.resetModules();
({
+ applyPluginDoctorCompatibilityMigrations,
clearPluginDoctorContractRegistryCache,
+ collectRelevantDoctorPluginIds,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
listPluginDoctorSessionRouteStateOwners,
@@ -347,6 +351,80 @@ describe("doctor-contract-registry module loader", () => {
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2);
});
+ it("collects model provider ids for doctor compatibility migrations", () => {
+ expect(
+ collectRelevantDoctorPluginIds({
+ models: {
+ providers: {
+ "ollama-cloud": {
+ baseUrl: "https://ai.ollama.com",
+ },
+ },
+ },
+ }),
+ ).toEqual(["ollama-cloud"]);
+ });
+
+ it("loads a plugin doctor contract when scoped by a contributed provider id", () => {
+ const pluginRoot = makeTempDir();
+ fs.writeFileSync(path.join(pluginRoot, "doctor-contract-api.ts"), "export {};\n", "utf-8");
+ mocks.createJiti.mockImplementation(() => () => ({
+ normalizeCompatibilityConfig: ({
+ cfg,
+ }: {
+ cfg: { models?: { providers?: Record> } };
+ }) => ({
+ config: {
+ ...cfg,
+ models: {
+ ...cfg.models,
+ providers: {
+ ...cfg.models?.providers,
+ "ollama-cloud": {
+ ...cfg.models?.providers?.["ollama-cloud"],
+ baseUrl: "https://ollama.com",
+ },
+ },
+ },
+ },
+ changes: ["normalized ollama cloud provider endpoint"],
+ }),
+ }));
+ mocks.loadPluginManifestRegistry.mockReturnValue({
+ plugins: [
+ {
+ id: "ollama",
+ rootDir: pluginRoot,
+ channels: [],
+ providers: ["ollama", "ollama-cloud"],
+ },
+ ],
+ diagnostics: [],
+ });
+ const config = {
+ models: {
+ providers: {
+ "ollama-cloud": {
+ baseUrl: "https://ai.ollama.com",
+ models: [],
+ },
+ },
+ },
+ };
+
+ const result = applyPluginDoctorCompatibilityMigrations(config, {
+ config,
+ env: {},
+ pluginIds: ["ollama-cloud"],
+ });
+
+ expect(result.changes).toEqual(["normalized ollama cloud provider endpoint"]);
+ expect(result.config.models?.providers?.["ollama-cloud"]).toEqual({
+ baseUrl: "https://ollama.com",
+ models: [],
+ });
+ });
+
it("narrows touched-path doctor ids for scoped dry-run validation", () => {
expect(
collectRelevantDoctorPluginIdsForTouchedPaths({
@@ -360,6 +438,11 @@ describe("doctor-contract-registry module loader", () => {
"memory-wiki": {},
},
},
+ models: {
+ providers: {
+ "ollama-cloud": {},
+ },
+ },
talk: {
voiceId: "legacy-voice",
},
@@ -367,10 +450,11 @@ describe("doctor-contract-registry module loader", () => {
touchedPaths: [
["channels", "discord", "token"],
["plugins", "entries", "memory-wiki", "enabled"],
+ ["models", "providers", "ollama-cloud", "baseUrl"],
["talk", "voiceId"],
],
}),
- ).toEqual(["discord", "elevenlabs", "memory-wiki"]);
+ ).toEqual(["discord", "elevenlabs", "memory-wiki", "ollama-cloud"]);
});
it("falls back to the full doctor-id set when touched paths are too broad", () => {
diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts
index 69d56f9fe442..1385c1cfdfcb 100644
--- a/src/plugins/doctor-contract-registry.ts
+++ b/src/plugins/doctor-contract-registry.ts
@@ -244,6 +244,13 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
}
}
+ const modelProviders = asNullableRecord(asNullableRecord(root.models)?.providers);
+ if (modelProviders) {
+ for (const providerId of Object.keys(modelProviders)) {
+ ids.add(providerId);
+ }
+ }
+
if (hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}
@@ -279,6 +286,13 @@ export function collectRelevantDoctorPluginIdsForTouchedPaths(params: {
ids.add(third);
continue;
}
+ if (first === "models") {
+ if (second !== "providers" || !third) {
+ return collectRelevantDoctorPluginIds(params.raw);
+ }
+ ids.add(third);
+ continue;
+ }
if (first === "talk" && hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}
From add00d747b6dd367922f29d0b9d42da72e6a3bcb Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:19:01 +0800
Subject: [PATCH 09/24] build(docs): finish PowerShell-safe docs formatting
(#93512)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: yil337 <220073147+yil337@users.noreply.github.com>
---
scripts/format-docs.mjs | 272 +++++++++++++++++++++++++------
test/scripts/format-docs.test.ts | 154 +++++++++++++++++
2 files changed, 372 insertions(+), 54 deletions(-)
create mode 100644 test/scripts/format-docs.test.ts
diff --git a/scripts/format-docs.mjs b/scripts/format-docs.mjs
index 7b4ed5c0c7ce..72a70e7d797c 100644
--- a/scripts/format-docs.mjs
+++ b/scripts/format-docs.mjs
@@ -1,46 +1,189 @@
#!/usr/bin/env node
// Formats docs Markdown/MDX and repairs Mintlify accordion indentation.
-import { execFileSync, spawnSync } from "node:child_process";
+import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
+import { pathToFileURL } from "node:url";
import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs";
+import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
const ROOT = path.resolve(import.meta.dirname, "..");
const CHECK = process.argv.includes("--check");
-const OXFMT_BIN = path.join(ROOT, "node_modules", "oxfmt", "bin", "oxfmt");
-const OXFMT_CONFIG = path.join(ROOT, ".oxfmtrc.jsonc");
+const DOCS_FORMAT_MAX_BUFFER_BYTES = 1024 * 1024 * 16;
+export const DOCS_FORMAT_MAX_COMMAND_LINE_BYTES = 24 * 1024;
+const FAILURE_OUTPUT_TAIL_BYTES = 16 * 1024;
-function docsFiles() {
- const output = execFileSync("git", ["ls-files", "docs/**/*.md", "docs/**/*.mdx", "README.md"], {
- cwd: ROOT,
- encoding: "utf8",
- });
- return output
- .split("\n")
- .filter(Boolean)
- .filter((relativePath) => fs.existsSync(path.join(ROOT, relativePath)));
+function outputText(value) {
+ if (typeof value === "string") {
+ return value;
+ }
+ if (Buffer.isBuffer(value)) {
+ return value.toString("utf8");
+ }
+ return "";
}
-function runOxfmt(files) {
- const result = spawnSync(
- process.execPath,
- [OXFMT_BIN, "--write", "--threads=1", "--config", OXFMT_CONFIG, ...files],
- {
- cwd: ROOT,
- encoding: "utf8",
- maxBuffer: 1024 * 1024 * 16,
- },
- );
+function outputTail(value) {
+ const text = outputText(value).trim();
+ if (!text) {
+ return "";
+ }
+ const bytes = Buffer.from(text, "utf8");
+ if (bytes.byteLength <= FAILURE_OUTPUT_TAIL_BYTES) {
+ return text;
+ }
+ return bytes.subarray(bytes.byteLength - FAILURE_OUTPUT_TAIL_BYTES).toString("utf8");
+}
- if (result.status !== 0) {
- const stderr = result.stderr.trim();
- throw new Error(`oxfmt failed${stderr ? `:\n${stderr}` : ""}`);
+function commandFailureMessage(label, result, invocation) {
+ const details = [];
+ if (invocation) {
+ details.push(`command: ${invocation.command}`);
+ if (invocation.args.length > 0) {
+ const previewArgs = invocation.args.slice(0, 12).join(" ");
+ const suffix = invocation.args.length > 12 ? ` ... (${invocation.args.length} args)` : "";
+ details.push(`args: ${previewArgs}${suffix}`);
+ }
+ }
+ if (result.error?.message) {
+ details.push(result.error.message);
+ }
+ if (result.status !== null && result.status !== undefined && result.status !== 0) {
+ details.push(`exit status: ${result.status}`);
+ }
+ if (result.signal) {
+ details.push(`signal: ${result.signal}`);
+ }
+ const stderrTail = outputTail(result.stderr);
+ if (stderrTail) {
+ details.push(`stderr tail:\n${stderrTail}`);
+ }
+ const stdoutTail = outputTail(result.stdout);
+ if (stdoutTail) {
+ details.push(`stdout tail:\n${stdoutTail}`);
+ }
+ return `${label} failed${details.length > 0 ? `:\n${details.join("\n")}` : ""}`;
+}
+
+export function docsFiles(root = ROOT, deps = {}) {
+ const spawnSyncImpl = deps.spawnSync ?? spawnSync;
+ const result = spawnSyncImpl("git", ["ls-files", "docs/**/*.md", "docs/**/*.mdx", "README.md"], {
+ cwd: root,
+ encoding: "utf8",
+ maxBuffer: DOCS_FORMAT_MAX_BUFFER_BYTES,
+ });
+ if (result.error || result.status !== 0) {
+ throw new Error(
+ commandFailureMessage("git ls-files", result, {
+ command: "git",
+ args: ["ls-files", "docs/**/*.md", "docs/**/*.mdx", "README.md"],
+ }),
+ );
+ }
+ return outputText(result.stdout)
+ .split("\n")
+ .filter(Boolean)
+ .filter((relativePath) => (deps.existsSync ?? fs.existsSync)(path.join(root, relativePath)));
+}
+
+function commandLineBytes(args) {
+ return args.reduce((total, arg) => total + Buffer.byteLength(arg, "utf8") + 3, 0);
+}
+
+export function chunkFilesForCommand(
+ files,
+ prefixArgs,
+ maxBytes = DOCS_FORMAT_MAX_COMMAND_LINE_BYTES,
+) {
+ const chunks = [];
+ let chunk = [];
+ let chunkBytes = commandLineBytes(prefixArgs);
+
+ for (const file of files) {
+ const fileBytes = Buffer.byteLength(file, "utf8") + 3;
+ if (chunk.length > 0 && chunkBytes + fileBytes > maxBytes) {
+ chunks.push(chunk);
+ chunk = [];
+ chunkBytes = commandLineBytes(prefixArgs);
+ }
+ chunk.push(file);
+ chunkBytes += fileBytes;
+ }
+
+ if (chunk.length > 0) {
+ chunks.push(chunk);
+ }
+
+ return chunks;
+}
+
+export function resolveOxfmtInvocation(args, params = {}) {
+ const repoRoot = params.repoRoot ?? ROOT;
+ const platform = params.platform ?? process.platform;
+ const existsSync = params.existsSync ?? fs.existsSync;
+ const shimName = platform === "win32" ? "oxfmt.cmd" : "oxfmt";
+ const shimPath = path.join(repoRoot, "node_modules", ".bin", shimName);
+
+ if (existsSync(shimPath)) {
+ if (platform === "win32") {
+ const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe";
+ return {
+ command: comSpec,
+ args: ["/d", "/s", "/c", buildCmdExeCommandLine(shimPath, args)],
+ shell: false,
+ windowsVerbatimArguments: true,
+ };
+ }
+ return {
+ command: shimPath,
+ args,
+ shell: false,
+ };
+ }
+
+ return {
+ command: params.nodeExecPath ?? process.execPath,
+ args: [path.join(repoRoot, "node_modules", "oxfmt", "bin", "oxfmt"), ...args],
+ shell: false,
+ };
+}
+
+export function runOxfmt(files, params = {}, deps = {}) {
+ if (files.length === 0) {
+ return;
+ }
+ const repoRoot = params.repoRoot ?? ROOT;
+ const spawnSyncImpl = deps.spawnSync ?? spawnSync;
+ const prefixArgs = ["--write", "--threads=1", "--config", path.join(repoRoot, ".oxfmtrc.jsonc")];
+ for (const chunk of chunkFilesForCommand(
+ files,
+ prefixArgs,
+ params.maxCommandLineBytes ?? DOCS_FORMAT_MAX_COMMAND_LINE_BYTES,
+ )) {
+ const invocation = resolveOxfmtInvocation([...prefixArgs, ...chunk], {
+ comSpec: params.comSpec,
+ existsSync: deps.existsSync,
+ nodeExecPath: params.nodeExecPath,
+ platform: params.platform,
+ repoRoot,
+ });
+ const result = spawnSyncImpl(invocation.command, invocation.args, {
+ cwd: repoRoot,
+ encoding: "utf8",
+ maxBuffer: DOCS_FORMAT_MAX_BUFFER_BYTES,
+ shell: invocation.shell,
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
+ });
+
+ if (result.error || result.status !== 0) {
+ throw new Error(commandFailureMessage("oxfmt", result, invocation));
+ }
}
}
-function repairFiles(root, files) {
+export function repairFiles(root, files) {
const changed = [];
for (const relativePath of files) {
const absolutePath = path.join(root, relativePath);
@@ -55,10 +198,10 @@ function repairFiles(root, files) {
return changed;
}
-function copyDocsToTemp(files) {
+function copyDocsToTemp(root, files) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-format-"));
for (const relativePath of files) {
- const source = path.join(ROOT, relativePath);
+ const source = path.join(root, relativePath);
const target = path.join(tempRoot, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
@@ -66,39 +209,60 @@ function copyDocsToTemp(files) {
return tempRoot;
}
-const changed = [];
-const files = docsFiles();
+export function formatDocs(params = {}, deps = {}) {
+ const root = params.root ?? ROOT;
+ const check = params.check ?? false;
+ const changed = [];
+ const files = docsFiles(root, deps);
-if (CHECK) {
- const tempRoot = copyDocsToTemp(files);
- try {
- runOxfmt(files.map((relativePath) => path.join(tempRoot, relativePath)));
- repairFiles(tempRoot, files);
- for (const relativePath of files) {
- const raw = fs.readFileSync(path.join(ROOT, relativePath), "utf8");
- const formatted = fs.readFileSync(path.join(tempRoot, relativePath), "utf8");
- if (formatted !== raw) {
- changed.push(relativePath);
+ if (check) {
+ const tempRoot = copyDocsToTemp(root, files);
+ try {
+ runOxfmt(
+ files.map((relativePath) => path.join(tempRoot, relativePath)),
+ { ...params, repoRoot: root },
+ deps,
+ );
+ repairFiles(tempRoot, files);
+ for (const relativePath of files) {
+ const raw = fs.readFileSync(path.join(root, relativePath), "utf8");
+ const formatted = fs.readFileSync(path.join(tempRoot, relativePath), "utf8");
+ if (formatted !== raw) {
+ changed.push(relativePath);
+ }
}
+ } finally {
+ fs.rmSync(tempRoot, { recursive: true, force: true });
}
- } finally {
- fs.rmSync(tempRoot, { recursive: true, force: true });
+ } else {
+ runOxfmt(files, { ...params, repoRoot: root }, deps);
+ changed.push(...repairFiles(root, files));
}
-} else {
- runOxfmt(files);
- changed.push(...repairFiles(ROOT, files));
+
+ return {
+ changed,
+ fileCount: files.length,
+ };
}
-if (CHECK && changed.length > 0) {
- console.error(`Format issues found in ${changed.length} docs file(s):`);
- for (const relativePath of changed) {
- console.error(`- ${relativePath}`);
+function main() {
+ const { changed, fileCount } = formatDocs({ check: CHECK, root: ROOT });
+
+ if (CHECK && changed.length > 0) {
+ console.error(`Format issues found in ${changed.length} docs file(s):`);
+ for (const relativePath of changed) {
+ console.error(`- ${relativePath}`);
+ }
+ process.exit(1);
+ }
+
+ if (changed.length > 0) {
+ console.log(`Formatted ${changed.length} docs file(s).`);
+ } else {
+ console.log(`Docs formatting clean (${fileCount} files).`);
}
- process.exit(1);
}
-if (changed.length > 0) {
- console.log(`Formatted ${changed.length} docs file(s).`);
-} else {
- console.log(`Docs formatting clean (${files.length} files).`);
+if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+ main();
}
diff --git a/test/scripts/format-docs.test.ts b/test/scripts/format-docs.test.ts
new file mode 100644
index 000000000000..72193fdc5ad3
--- /dev/null
+++ b/test/scripts/format-docs.test.ts
@@ -0,0 +1,154 @@
+// Format Docs tests cover the docs formatter helper process spawning.
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ chunkFilesForCommand,
+ docsFiles,
+ formatDocs,
+ resolveOxfmtInvocation,
+ runOxfmt,
+} from "../../scripts/format-docs.mjs";
+import { createScriptTestHarness } from "./test-helpers.js";
+
+const { createTempDir } = createScriptTestHarness();
+
+function writeDocsFixture(root: string): void {
+ fs.mkdirSync(path.join(root, "docs"), { recursive: true });
+ fs.writeFileSync(path.join(root, "README.md"), "# OpenClaw\n", "utf8");
+ fs.writeFileSync(path.join(root, "docs", "guide.mdx"), "# Guide\n", "utf8");
+}
+
+describe("format-docs", () => {
+ it("wraps the Windows oxfmt.cmd shim through cmd.exe", () => {
+ const invocation = resolveOxfmtInvocation(["--write", "docs\\guide.mdx"], {
+ comSpec: "C:\\Windows\\System32\\cmd.exe",
+ existsSync: (candidate: string) => candidate.endsWith("oxfmt.cmd"),
+ platform: "win32",
+ repoRoot: "C:\\repo",
+ });
+
+ expect(invocation.command).toBe("C:\\Windows\\System32\\cmd.exe");
+ expect(invocation.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]);
+ expect(invocation.args[3]).toContain("oxfmt.cmd");
+ expect(invocation.args[3]).toContain("--write");
+ expect(invocation.args[3]).toContain("docs\\guide.mdx");
+ expect(invocation.shell).toBe(false);
+ expect(invocation.windowsVerbatimArguments).toBe(true);
+ });
+
+ it("batches oxfmt invocations when docs exceed the command line budget", () => {
+ const root = createTempDir("openclaw-format-docs-batch-");
+ const calls: Array<{ args: string[]; command: string }> = [];
+
+ runOxfmt(
+ ["docs/one.md", "docs/two.md", "docs/three.md"],
+ {
+ maxCommandLineBytes: 1,
+ repoRoot: root,
+ },
+ {
+ existsSync: () => false,
+ spawnSync: (command: string, args: string[]) => {
+ calls.push({ args, command });
+ return { status: 0, stderr: "", stdout: "" };
+ },
+ },
+ );
+
+ expect(calls).toHaveLength(3);
+ expect(calls.every((call) => call.command === process.execPath)).toBe(true);
+ expect(calls.map((call) => call.args.at(-1))).toEqual([
+ "docs/one.md",
+ "docs/two.md",
+ "docs/three.md",
+ ]);
+ });
+
+ it("reports git and oxfmt spawn diagnostics", () => {
+ const root = createTempDir("openclaw-format-docs-failures-");
+
+ expect(() =>
+ docsFiles(root, {
+ spawnSync: () => ({
+ status: 128,
+ stderr: "fatal: not a git repository",
+ stdout: "",
+ }),
+ }),
+ ).toThrow(/git ls-files failed:[\s\S]*exit status: 128[\s\S]*fatal: not a git repository/u);
+
+ expect(() =>
+ runOxfmt(
+ ["README.md"],
+ { repoRoot: root },
+ {
+ existsSync: () => false,
+ spawnSync: () => ({
+ status: 1,
+ stderr: "formatter stderr",
+ stdout: "formatter stdout",
+ }),
+ },
+ ),
+ ).toThrow(
+ /oxfmt failed:[\s\S]*command:[\s\S]*exit status: 1[\s\S]*formatter stderr[\s\S]*formatter stdout/u,
+ );
+ });
+
+ it("uses repository paths in write mode and temporary paths in check mode", () => {
+ const root = createTempDir("openclaw-format-docs-mode-");
+ writeDocsFixture(root);
+ const oxfmtFileArgs: string[][] = [];
+
+ const spawnSync = (command: string, args: string[]) => {
+ if (command === "git") {
+ return {
+ status: 0,
+ stderr: "",
+ stdout: "README.md\ndocs/guide.mdx\n",
+ };
+ }
+ oxfmtFileArgs.push(args.slice(-2));
+ return { status: 0, stderr: "", stdout: "" };
+ };
+
+ expect(
+ formatDocs(
+ {
+ check: false,
+ repoRoot: root,
+ root,
+ },
+ {
+ existsSync: fs.existsSync,
+ spawnSync,
+ },
+ ),
+ ).toEqual({ changed: [], fileCount: 2 });
+
+ expect(
+ formatDocs(
+ {
+ check: true,
+ repoRoot: root,
+ root,
+ },
+ {
+ existsSync: fs.existsSync,
+ spawnSync,
+ },
+ ),
+ ).toEqual({ changed: [], fileCount: 2 });
+
+ expect(oxfmtFileArgs[0]).toEqual(["README.md", "docs/guide.mdx"]);
+ expect(oxfmtFileArgs[1]?.every((filePath) => path.isAbsolute(filePath))).toBe(true);
+ expect(oxfmtFileArgs[1]?.every((filePath) => filePath.startsWith(root))).toBe(false);
+ });
+
+ it("keeps single oversized docs in their own command chunk", () => {
+ expect(chunkFilesForCommand(["docs/very-long-name.md"], ["--write"], 1)).toEqual([
+ ["docs/very-long-name.md"],
+ ]);
+ });
+});
From a664c44375897e582f2f15b59099c5fd22b73e0c Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 08:16:47 +0200
Subject: [PATCH 10/24] fix(scripts): create extension memory report dirs
---
scripts/profile-extension-memory.mjs | 3 ++-
test/scripts/profile-extension-memory.test.ts | 21 +++++++++++++++++++
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs
index 1c07301036b1..68c45c02acbb 100644
--- a/scripts/profile-extension-memory.mjs
+++ b/scripts/profile-extension-memory.mjs
@@ -3,7 +3,7 @@
// Profiles peak RSS for built bundled plugin entrypoints and emits a JSON
// report suitable for extension memory budget review.
import { spawn } from "node:child_process";
-import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -434,6 +434,7 @@ async function main() {
results,
};
+ mkdirSync(path.dirname(jsonPath), { recursive: true });
writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`[extension-memory] report: ${jsonPath}`);
diff --git a/test/scripts/profile-extension-memory.test.ts b/test/scripts/profile-extension-memory.test.ts
index f208c7f8963f..d4281be90923 100644
--- a/test/scripts/profile-extension-memory.test.ts
+++ b/test/scripts/profile-extension-memory.test.ts
@@ -94,6 +94,27 @@ describe("scripts/profile-extension-memory", () => {
}
});
+ it("creates parent directories for nested JSON report paths", () => {
+ const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
+ try {
+ const extensionDir = path.join(root, "dist", "extensions", "simple");
+ const reportPath = path.join(root, ".artifacts", "memory", "report.json");
+ mkdirSync(extensionDir, { recursive: true });
+ writeFileSync(path.join(extensionDir, "index.js"), `export default {};\n`, "utf8");
+
+ const result = runProfileExtensionMemory(
+ ["--extension", "simple", "--skip-combined", "--concurrency", "1", "--json", reportPath],
+ root,
+ );
+
+ expect(result.status, result.stderr).toBe(0);
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
+ expect(report.counts).toMatchObject({ totalEntries: 1, ok: 1, fail: 0, timeout: 0 });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
it("fails when a profiled plugin import fails", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
try {
From 610c76087b96b0d4f51d92b3e221ff4e3d195ac2 Mon Sep 17 00:00:00 2001
From: zhang-guiping
Date: Tue, 16 Jun 2026 14:20:24 +0800
Subject: [PATCH 11/24] [Bug]: ollama-cloud runtime fails DNS lookup for
ai.ollama.com, while ollama/:cloud works (#92594)
* fix(ollama): repair retired cloud provider endpoint
Route configured Ollama Cloud provider ids through plugin doctor compatibility migrations so doctor --fix can rewrite the retired ai.ollama.com endpoint before runtime reads persisted config.
Co-Authored-By: Claude Opus 4.7
* test(doctor): align provider fixture with typed config
Ensure the doctor registry provider-scoped migration test uses a fully typed provider fixture so the test type-check shard validates the intended behavior.
Co-Authored-By: Claude Opus 4.7
* test(ollama): align doctor fixture with typed config
Use fully typed provider and model fixtures in the Ollama doctor contract tests so the extension test type-check shard validates the migration behavior.
Co-Authored-By: Claude Opus 4.7
* fix(ollama): preserve custom cloud provider base url
Co-Authored-By: Claude Opus 4.7
* fix(ollama): avoid logging retired endpoint secrets
---------
Co-authored-by: Claude Opus 4.7
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
From 1884cedd350e600469c1e5cd8fd90f21ec0bec85 Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:20:47 +0800
Subject: [PATCH 12/24] fix(skills): refresh persisted snapshots after restart
(#93513)
* fix(skills): refresh persisted snapshots after restart
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: Oleksandr Zakotyanskyi <28755978+fif911@users.noreply.github.com>
Co-authored-by: Stephan Kadauke <10904538+skadauke@users.noreply.github.com>
* fix(clownfish): address review for ghcrawl-156600-autonomous-smoke (1)
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: Oleksandr Zakotyanskyi <28755978+fif911@users.noreply.github.com>
Co-authored-by: Stephan Kadauke <10904538+skadauke@users.noreply.github.com>
---------
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Stephan Kadauke <10904538+skadauke@users.noreply.github.com>
---
src/skills/runtime/refresh-state.test.ts | 44 ++++++++++++++++
src/skills/runtime/refresh-state.ts | 5 +-
src/skills/runtime/session-snapshot.test.ts | 57 +++++++++++++++++----
3 files changed, 94 insertions(+), 12 deletions(-)
create mode 100644 src/skills/runtime/refresh-state.test.ts
diff --git a/src/skills/runtime/refresh-state.test.ts b/src/skills/runtime/refresh-state.test.ts
new file mode 100644
index 000000000000..ae96d1d895a2
--- /dev/null
+++ b/src/skills/runtime/refresh-state.test.ts
@@ -0,0 +1,44 @@
+// Skill refresh state tests cover snapshot version invalidation contracts.
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ bumpSkillsSnapshotVersion,
+ getSkillsSnapshotVersion,
+ resetSkillsRefreshStateForTest,
+ shouldRefreshSnapshotForVersion,
+} from "./refresh-state.js";
+
+describe("skills refresh state", () => {
+ beforeEach(() => {
+ resetSkillsRefreshStateForTest();
+ });
+
+ it("starts above persisted version 0 so restarted sessions refresh once", () => {
+ const currentVersion = getSkillsSnapshotVersion("/tmp/workspace");
+
+ expect(currentVersion).toBeGreaterThan(0);
+ expect(shouldRefreshSnapshotForVersion(0, currentVersion)).toBe(true);
+ });
+
+ it("starts above persisted timestamp versions from earlier processes", () => {
+ const currentVersion = getSkillsSnapshotVersion("/tmp/workspace");
+ const previousProcessVersion = currentVersion - 1;
+
+ expect(shouldRefreshSnapshotForVersion(previousProcessVersion, currentVersion)).toBe(true);
+ });
+
+ it("reuses snapshots already built for the current startup version", () => {
+ const currentVersion = getSkillsSnapshotVersion("/tmp/workspace");
+
+ expect(shouldRefreshSnapshotForVersion(currentVersion, currentVersion)).toBe(false);
+ });
+
+ it("keeps workspace and global bumps above the startup version", () => {
+ const startupVersion = getSkillsSnapshotVersion("/tmp/workspace");
+ const workspaceVersion = bumpSkillsSnapshotVersion({ workspaceDir: "/tmp/workspace" });
+ const globalVersion = bumpSkillsSnapshotVersion();
+
+ expect(workspaceVersion).toBeGreaterThan(startupVersion);
+ expect(globalVersion).toBeGreaterThanOrEqual(workspaceVersion);
+ expect(getSkillsSnapshotVersion("/tmp/workspace")).toBe(globalVersion);
+ });
+});
diff --git a/src/skills/runtime/refresh-state.ts b/src/skills/runtime/refresh-state.ts
index dc866d30ec35..ea93101ba099 100644
--- a/src/skills/runtime/refresh-state.ts
+++ b/src/skills/runtime/refresh-state.ts
@@ -7,7 +7,8 @@ export type SkillsChangeEvent = {
const listeners = new Set<(event: SkillsChangeEvent) => void>();
const workspaceVersions = new Map();
-let globalVersion = 0;
+const INITIAL_SKILLS_SNAPSHOT_VERSION = Date.now();
+let globalVersion = INITIAL_SKILLS_SNAPSHOT_VERSION;
let listenerErrorHandler: ((err: unknown) => void) | undefined;
function bumpVersion(current: number): number {
@@ -85,6 +86,6 @@ export function shouldRefreshSnapshotForVersion(
export function resetSkillsRefreshStateForTest(): void {
listeners.clear();
workspaceVersions.clear();
- globalVersion = 0;
+ globalVersion = INITIAL_SKILLS_SNAPSHOT_VERSION;
listenerErrorHandler = undefined;
}
diff --git a/src/skills/runtime/session-snapshot.test.ts b/src/skills/runtime/session-snapshot.test.ts
index 91c03d7ef9cc..503700972bd2 100644
--- a/src/skills/runtime/session-snapshot.test.ts
+++ b/src/skills/runtime/session-snapshot.test.ts
@@ -6,11 +6,11 @@ import type { SkillSnapshot } from "../types.js";
const TEST_WORKSPACE_DIR = "/tmp/workspace";
-function strippedSnapshot(skillName = "test"): SkillSnapshot {
+function strippedSnapshot(skillName = "test", version = 1): SkillSnapshot {
return {
prompt: "skills prompt",
skills: [{ name: skillName }],
- version: 0,
+ version,
promptFormatVersion: WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION,
};
}
@@ -27,8 +27,10 @@ const {
resolvedSkills: [] as unknown[],
})),
ensureSkillsWatcherMock: vi.fn(),
- getSkillsSnapshotVersionMock: vi.fn(() => 0),
- shouldRefreshSnapshotForVersionMock: vi.fn((_cached?: number, _next?: number) => false),
+ getSkillsSnapshotVersionMock: vi.fn(() => 1),
+ shouldRefreshSnapshotForVersionMock: vi.fn((cached = 0, next = 0) =>
+ next === 0 ? cached > 0 : cached < next,
+ ),
}));
vi.mock("../loading/workspace.js", () => ({
@@ -52,8 +54,10 @@ describe("resolveReusableWorkspaceSkillSnapshot", () => {
vi.clearAllMocks();
resetResolvedSkillsCacheForTests();
buildWorkspaceSkillSnapshotMock.mockReturnValue({ prompt: "", skills: [], resolvedSkills: [] });
- getSkillsSnapshotVersionMock.mockReturnValue(0);
- shouldRefreshSnapshotForVersionMock.mockReturnValue(false);
+ getSkillsSnapshotVersionMock.mockReturnValue(1);
+ shouldRefreshSnapshotForVersionMock.mockImplementation((cached = 0, next = 0) =>
+ next === 0 ? cached > 0 : cached < next,
+ );
});
it("reuses cached resolvedSkills across calls with the same workspace, version, and filter", () => {
@@ -97,19 +101,18 @@ describe("resolveReusableWorkspaceSkillSnapshot", () => {
});
it("reads the skills snapshot version after watcher-side invalidation", () => {
- getSkillsSnapshotVersionMock.mockReturnValue(0);
+ getSkillsSnapshotVersionMock.mockReturnValue(1);
ensureSkillsWatcherMock.mockImplementation(() => {
getSkillsSnapshotVersionMock.mockReturnValue(5);
});
- shouldRefreshSnapshotForVersionMock.mockImplementation((cached = 0, next = 0) => cached < next);
resolveReusableWorkspaceSkillSnapshot({
workspaceDir: TEST_WORKSPACE_DIR,
config: { skills: { load: { extraDirs: ["/tmp/shared-skills"] } } },
- existingSnapshot: strippedSnapshot(),
+ existingSnapshot: strippedSnapshot("test", 1),
});
- expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(0, 5);
+ expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(1, 5);
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
const [[, snapshotParams]] = buildWorkspaceSkillSnapshotMock.mock.calls as unknown as Array<
[string, { snapshotVersion?: number }]
@@ -117,6 +120,40 @@ describe("resolveReusableWorkspaceSkillSnapshot", () => {
expect(snapshotParams.snapshotVersion).toBe(5);
});
+ it("refreshes persisted version-0 snapshots after process restart", () => {
+ const result = resolveReusableWorkspaceSkillSnapshot({
+ workspaceDir: TEST_WORKSPACE_DIR,
+ config: {},
+ existingSnapshot: strippedSnapshot("test", 0),
+ });
+
+ expect(result.shouldRefresh).toBe(true);
+ expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(0, 1);
+ expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
+ const [[, snapshotParams]] = buildWorkspaceSkillSnapshotMock.mock.calls as unknown as Array<
+ [string, { snapshotVersion?: number }]
+ >;
+ expect(snapshotParams.snapshotVersion).toBe(1);
+ });
+
+ it("refreshes persisted timestamp-version snapshots from earlier processes", () => {
+ getSkillsSnapshotVersionMock.mockReturnValue(10_000);
+
+ const result = resolveReusableWorkspaceSkillSnapshot({
+ workspaceDir: TEST_WORKSPACE_DIR,
+ config: {},
+ existingSnapshot: strippedSnapshot("test", 9_999),
+ });
+
+ expect(result.shouldRefresh).toBe(true);
+ expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(9_999, 10_000);
+ expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
+ const [[, snapshotParams]] = buildWorkspaceSkillSnapshotMock.mock.calls as unknown as Array<
+ [string, { snapshotVersion?: number }]
+ >;
+ expect(snapshotParams.snapshotVersion).toBe(10_000);
+ });
+
it("invalidates cached resolvedSkills when non-skills config gates change", () => {
buildWorkspaceSkillSnapshotMock.mockImplementation((_workspaceDir, opts) => {
const config = (opts as { config?: { channels?: { discord?: { token?: string } } } }).config;
From ce6fd932798199e1f3bc6b2f997e01c07b89445a Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:24:19 +0800
Subject: [PATCH 13/24] fix(skills): quote skill-creator template description
(#93517)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: parubets <1392109+parubets@users.noreply.github.com>
---
skills/skill-creator/scripts/init_skill.py | 2 +-
.../skill-creator/scripts/test_init_skill.py | 51 +++++++++++++++++++
2 files changed, 52 insertions(+), 1 deletion(-)
create mode 100644 skills/skill-creator/scripts/test_init_skill.py
diff --git a/skills/skill-creator/scripts/init_skill.py b/skills/skill-creator/scripts/init_skill.py
index 8633fe9e3f2d..05e63e50c95e 100644
--- a/skills/skill-creator/scripts/init_skill.py
+++ b/skills/skill-creator/scripts/init_skill.py
@@ -22,7 +22,7 @@ ALLOWED_RESOURCES = {"scripts", "references", "assets"}
SKILL_TEMPLATE = """---
name: {skill_name}
-description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
+description: '[TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]'
---
# {skill_title}
diff --git a/skills/skill-creator/scripts/test_init_skill.py b/skills/skill-creator/scripts/test_init_skill.py
new file mode 100644
index 000000000000..47babdce3185
--- /dev/null
+++ b/skills/skill-creator/scripts/test_init_skill.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+"""
+Regression tests for skill initialization.
+"""
+
+import shutil
+import sys
+import tempfile
+from contextlib import redirect_stdout
+from io import StringIO
+from pathlib import Path
+from unittest import TestCase, main
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+if str(SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPT_DIR))
+
+import init_skill
+
+
+class TestInitSkill(TestCase):
+ def setUp(self):
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="test_init_skill_"))
+
+ def tearDown(self):
+ if self.temp_dir.exists():
+ shutil.rmtree(self.temp_dir)
+
+ def test_generated_description_placeholder_is_yaml_string(self):
+ with redirect_stdout(StringIO()):
+ skill_dir = init_skill.init_skill("yaml-description-skill", self.temp_dir, [], False)
+
+ self.assertIsNotNone(skill_dir)
+ content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
+ frontmatter = content.split("---", 2)[1]
+
+ self.assertIn("description: '[TODO:", frontmatter)
+
+ try:
+ import yaml
+ except ImportError:
+ self.skipTest("PyYAML is not installed")
+
+ parsed = yaml.safe_load(frontmatter)
+
+ self.assertIsInstance(parsed["description"], str)
+ self.assertTrue(parsed["description"].startswith("[TODO: Complete"))
+
+
+if __name__ == "__main__":
+ main()
From e1d3f12d7f5b0a0fa0a8c58f8bab27ef1b20ffa1 Mon Sep 17 00:00:00 2001
From: "openclaw-clownfish[bot]"
<280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Date: Tue, 16 Jun 2026 14:25:02 +0800
Subject: [PATCH 14/24] fix(memory): use per-keyword FTS search in hybrid mode
#39484 (#73976)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
---
.../memory-core/src/memory/index.test.ts | 40 +++++++
extensions/memory-core/src/memory/manager.ts | 111 +++++++++++-------
2 files changed, 106 insertions(+), 45 deletions(-)
diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts
index 3760f44aea5f..9a9a850513bf 100644
--- a/extensions/memory-core/src/memory/index.test.ts
+++ b/extensions/memory-core/src/memory/index.test.ts
@@ -1616,6 +1616,46 @@ describe("memory index", () => {
);
});
+ it("bounds per-keyword FTS fallback in provider-backed hybrid search", async () => {
+ const cfg = createCfg({
+ storePath: indexMainPath,
+ minScore: 0.35,
+ hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
+ });
+ const manager = await getPersistentManager(cfg);
+ await manager.sync({ reason: "test" });
+
+ const db = (
+ manager as unknown as {
+ db: {
+ prepare: (sql: string) => unknown;
+ };
+ }
+ ).db;
+ const originalPrepare = db.prepare.bind(db);
+ let ftsSelects = 0;
+ const prepareSpy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
+ if (sql.includes("FROM chunks_fts") && sql.includes("WHERE chunks_fts MATCH ?")) {
+ ftsSelects += 1;
+ }
+ return originalPrepare(sql);
+ });
+
+ try {
+ const results = await manager.search(
+ "zebra project router gateway session transcript approval command owner workspace token budget retry queue",
+ { maxResults: 5 },
+ );
+
+ expect(results.length).toBeGreaterThan(0);
+ expect(results[0]?.path).toContain("memory/2026-01-12.md");
+ expect(ftsSelects).toBeGreaterThan(1);
+ expect(ftsSelects).toBeLessThanOrEqual(7);
+ } finally {
+ prepareSpy.mockRestore();
+ }
+ });
+
it("reports vector availability after probe", async () => {
const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true });
const manager = await getPersistentManager(cfg);
diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts
index c069991478cc..df4a05e8f942 100644
--- a/extensions/memory-core/src/memory/manager.ts
+++ b/extensions/memory-core/src/memory/manager.ts
@@ -71,6 +71,7 @@ const FTS_TABLE = "chunks_fts";
const EMBEDDING_CACHE_TABLE = "embedding_cache";
const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCache");
export const EMBEDDING_PROBE_CACHE_TTL_MS = 30_000;
+const KEYWORD_FALLBACK_SEARCH_TERM_LIMIT = 6;
const log = createSubsystemLogger("memory");
type MemoryIndexManagerPurpose = "default" | "status" | "cli";
type MemoryEmbeddingProviderRequirement = {
@@ -88,6 +89,8 @@ type EmbeddingProbeCacheEntry = {
expireAtMs: number;
};
+type KeywordSearchHit = MemorySearchResult & { id: string; textScore: number };
+
const EMBEDDING_PROBE_CACHE = new Map();
export async function closeAllMemoryIndexManagers(): Promise {
@@ -689,7 +692,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
return [];
}
- const fullQueryResults = await this.searchKeyword(
+ const keywordResults = await this.searchKeywordWithFallback(
cleaned,
candidates,
{
@@ -700,47 +703,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
log.warn(`memory search: FTS keyword query failed: ${formatErrorMessage(err)}`);
return [];
});
- const resultSets =
- fullQueryResults.length > 0
- ? [fullQueryResults]
- : await Promise.all(
- // Fallback: broaden recall for conversational queries when the
- // exact AND query is too strict to return any results.
- (() => {
- const keywords = extractKeywords(cleaned, {
- ftsTokenizer: this.settings.store.fts.tokenizer,
- });
- const searchTerms = keywords.length > 0 ? keywords : [cleaned];
- return searchTerms.map((term) =>
- this.searchKeyword(
- term,
- candidates,
- { boostFallbackRanking: true },
- sourceFilterList,
- ).catch((err: unknown) => {
- log.warn(
- `memory search: FTS per-keyword query failed for "${term}": ${formatErrorMessage(err)}`,
- );
- return [];
- }),
- );
- })(),
- );
- // Merge and deduplicate results, keeping highest score for each chunk
- const seenIds = new Map();
- for (const results of resultSets) {
- for (const result of results) {
- const existing = seenIds.get(result.id);
- if (!existing || result.score > existing.score) {
- seenIds.set(result.id, result);
- }
- }
- }
-
- const merged = [...seenIds.values()];
const decayed = await applyTemporalDecayToHybridResults({
- results: merged,
+ results: keywordResults,
temporalDecay: hybrid.temporalDecay,
workspaceDir: this.workspaceDir,
});
@@ -751,7 +716,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
const loadKeywordResults = async () =>
hybrid.enabled && this.fts.enabled && this.fts.available
- ? await this.searchKeyword(
+ ? await this.searchKeywordWithFallback(
cleaned,
candidates,
{ boostFallbackRanking: true },
@@ -824,8 +789,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
// Hybrid defaults can produce keyword-only matches below minScore after
- // weighting. If strict vector+keyword results are empty, preserve the FTS
- // matches; FTS already established lexical relevance.
+ // BM25 normalization and textWeight scaling. Preserve FTS-backed lexical
+ // hits when they are the only relevant results.
const relaxedMinScore = 0;
const keywordKeys = new Set(
keywordResults.map(
@@ -910,7 +875,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
limit: number,
options?: { boostFallbackRanking?: boolean },
sourceFilterList?: MemorySource[],
- ): Promise> {
+ ): Promise {
if (!this.fts.enabled || !this.fts.available) {
return [];
}
@@ -927,7 +892,63 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
bm25RankToScore,
boostFallbackRanking: options?.boostFallbackRanking,
});
- return results.map((entry) => entry as MemorySearchResult & { id: string; textScore: number });
+ return results.map((entry) => entry as KeywordSearchHit);
+ }
+
+ private async searchKeywordWithFallback(
+ query: string,
+ limit: number,
+ options: { boostFallbackRanking?: boolean } | undefined,
+ sourceFilterList: MemorySource[],
+ ): Promise {
+ const fullQueryResults = await this.searchKeyword(
+ query,
+ limit,
+ options,
+ sourceFilterList,
+ ).catch(() => []);
+ if (fullQueryResults.length > 0) {
+ return fullQueryResults;
+ }
+
+ // Broaden recall for conversational queries when the exact AND query is too
+ // strict, but cap the number of extra FTS probes so long prompts cannot fan
+ // out into unbounded sqlite work.
+ const fallbackTerms = this.resolveKeywordFallbackTerms(query);
+ if (fallbackTerms.length === 0) {
+ return [];
+ }
+
+ const resultSets = await Promise.all(
+ fallbackTerms.map((term) =>
+ this.searchKeyword(term, limit, options, sourceFilterList).catch(() => []),
+ ),
+ );
+ return this.mergeKeywordSearchHits(resultSets);
+ }
+
+ private resolveKeywordFallbackTerms(query: string): string[] {
+ const keywords = extractKeywords(query, {
+ ftsTokenizer: this.settings.store.fts.tokenizer,
+ }).filter((term) => term !== query);
+ return keywords.slice(0, KEYWORD_FALLBACK_SEARCH_TERM_LIMIT);
+ }
+
+ private mergeKeywordSearchHits(resultSets: KeywordSearchHit[][]): KeywordSearchHit[] {
+ const seenIds = new Map();
+ for (const results of resultSets) {
+ for (const result of results) {
+ const existing = seenIds.get(result.id);
+ if (
+ !existing ||
+ result.textScore > existing.textScore ||
+ (result.textScore === existing.textScore && result.score > existing.score)
+ ) {
+ seenIds.set(result.id, result);
+ }
+ }
+ }
+ return [...seenIds.values()].toSorted((a, b) => b.score - a.score);
}
private mergeHybridResults(params: {
From 52280351bb53c82a0452842d62b6fcd0fb2597a1 Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:30:01 +0800
Subject: [PATCH 15/24] fix(workspace): store setup state outside workspace
dot-dir (#93520)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Lai Quang Huy <64073540+1qh@users.noreply.github.com>
---
CHANGELOG.md | 1 +
src/agents/bootstrap-files.test.ts | 86 ++++++++++++------------
src/agents/workspace.test.ts | 56 +++++++++++++---
src/agents/workspace.ts | 101 ++++++++++++++++++++---------
4 files changed, 161 insertions(+), 83 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4514cb53e11c..df5aff694fa8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
+- Workspace setup state: store setup completion outside the workspace dot directory using an OpenClaw-named root file, migrate valid legacy state forward, and avoid clobbering generic root `workspace-state.json` files for TigerFS-style dot-path compatibility. This Clownfish replacement carries forward the focused #53326 fix idea because the original branch was closed and uneditable. (#53326, #44783, #39446) Thanks @1qh.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts
index 1446f3f55879..4346979b5fb6 100644
--- a/src/agents/bootstrap-files.test.ts
+++ b/src/agents/bootstrap-files.test.ts
@@ -108,6 +108,31 @@ async function createHeartbeatAgentsWorkspace() {
return workspaceDir;
}
+async function writeCompletedWorkspaceState(workspaceDir: string): Promise {
+ await fs.writeFile(
+ path.join(workspaceDir, "openclaw-workspace-state.json"),
+ `${JSON.stringify({
+ version: 1,
+ bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
+ setupCompletedAt: "2026-05-16T00:00:01.000Z",
+ })}\n`,
+ "utf8",
+ );
+}
+
+async function writeLegacyCompletedWorkspaceState(workspaceDir: string): Promise {
+ await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
+ await fs.writeFile(
+ path.join(workspaceDir, ".openclaw", "workspace-state.json"),
+ `${JSON.stringify({
+ version: 1,
+ bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
+ setupCompletedAt: "2026-05-16T00:00:01.000Z",
+ })}\n`,
+ "utf8",
+ );
+}
+
function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) {
// Heartbeat policy can remove HEARTBEAT.md for normal turns, but project rules
// must remain in the bootstrap set.
@@ -174,16 +199,7 @@ describe("resolveBootstrapFilesForRun", () => {
it("ignores stale workspace BOOTSTRAP.md once setup is completed", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
- await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
- await fs.writeFile(
- path.join(workspaceDir, ".openclaw", "workspace-state.json"),
- `${JSON.stringify({
- version: 1,
- bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
- setupCompletedAt: "2026-05-16T00:00:01.000Z",
- })}\n`,
- "utf8",
- );
+ await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -193,9 +209,21 @@ describe("resolveBootstrapFilesForRun", () => {
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
});
- it("keeps BOOTSTRAP.md when setup state cannot be read", async () => {
+ it("ignores stale workspace BOOTSTRAP.md when legacy setup state is completed", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
- await fs.mkdir(path.join(workspaceDir, ".openclaw", "workspace-state.json"), {
+ await writeLegacyCompletedWorkspaceState(workspaceDir);
+ await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
+ await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
+
+ const files = await resolveBootstrapFilesForRun({ workspaceDir });
+
+ expect(files.map((file) => file.name)).toContain("AGENTS.md");
+ expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
+ });
+
+ it("keeps BOOTSTRAP.md when current setup state cannot be read", async () => {
+ const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
+ await fs.mkdir(path.join(workspaceDir, "openclaw-workspace-state.json"), {
recursive: true,
});
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
@@ -209,16 +237,7 @@ describe("resolveBootstrapFilesForRun", () => {
it("does not let hooks re-add stale root BOOTSTRAP.md after setup is completed", async () => {
registerBootstrapFileHook();
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
- await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
- await fs.writeFile(
- path.join(workspaceDir, ".openclaw", "workspace-state.json"),
- `${JSON.stringify({
- version: 1,
- bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
- setupCompletedAt: "2026-05-16T00:00:01.000Z",
- })}\n`,
- "utf8",
- );
+ await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -231,16 +250,8 @@ describe("resolveBootstrapFilesForRun", () => {
registerBootstrapFileHook();
const parentDir = await makeTempWorkspace("openclaw-bootstrap-home-");
const workspaceDir = path.join(parentDir, "workspace");
- await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
- await fs.writeFile(
- path.join(workspaceDir, ".openclaw", "workspace-state.json"),
- `${JSON.stringify({
- version: 1,
- bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
- setupCompletedAt: "2026-05-16T00:00:01.000Z",
- })}\n`,
- "utf8",
- );
+ await fs.mkdir(workspaceDir, { recursive: true });
+ await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -263,17 +274,8 @@ describe("resolveBootstrapFilesForRun", () => {
it("keeps hook-added nested BOOTSTRAP.md after setup is completed", async () => {
registerBootstrapFileHook(path.join("packages", "core", "BOOTSTRAP.md"));
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
- await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.mkdir(path.join(workspaceDir, "packages", "core"), { recursive: true });
- await fs.writeFile(
- path.join(workspaceDir, ".openclaw", "workspace-state.json"),
- `${JSON.stringify({
- version: 1,
- bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
- setupCompletedAt: "2026-05-16T00:00:01.000Z",
- })}\n`,
- "utf8",
- );
+ await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
await fs.writeFile(
diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts
index f9adf53561e6..5ebe8495537d 100644
--- a/src/agents/workspace.test.ts
+++ b/src/agents/workspace.test.ts
@@ -66,7 +66,8 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
});
});
-const WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
+const WORKSPACE_STATE_PATH_SEGMENTS = ["openclaw-workspace-state.json"] as const;
+const LEGACY_WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
async function readWorkspaceState(dir: string): Promise<{
version: number;
@@ -81,6 +82,14 @@ async function readWorkspaceState(dir: string): Promise<{
};
}
+async function writeLegacyWorkspaceState(dir: string, state: unknown): Promise {
+ await fs.mkdir(path.join(dir, LEGACY_WORKSPACE_STATE_PATH_SEGMENTS[0]), { recursive: true });
+ await fs.writeFile(
+ path.join(dir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
+ `${JSON.stringify(state)}\n`,
+ );
+}
+
async function expectBootstrapSeeded(dir: string) {
await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined();
const state = await readWorkspaceState(dir);
@@ -128,9 +137,37 @@ describe("ensureAgentWorkspace", () => {
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
+ await expectPathMissing(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS));
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
+ it("does not overwrite a foreign root workspace-state.json file", async () => {
+ const tempDir = await makeTempWorkspace("openclaw-workspace-");
+ const foreignStatePath = path.join(tempDir, "workspace-state.json");
+ const foreignState = "not openclaw state\n";
+ await fs.writeFile(foreignStatePath, foreignState);
+
+ await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
+
+ expect(await fs.readFile(foreignStatePath, "utf-8")).toBe(foreignState);
+ await expectBootstrapSeeded(tempDir);
+ });
+
+ it("ignores unreadable legacy nested state while writing current setup state", async () => {
+ const tempDir = await makeTempWorkspace("openclaw-workspace-");
+ await fs.mkdir(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS), {
+ recursive: true,
+ });
+
+ await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
+
+ await expectBootstrapSeeded(tempDir);
+ const legacyStateStat = await fs.stat(
+ path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
+ );
+ expect(legacyStateStat.isDirectory()).toBe(true);
+ });
+
it("refuses to re-seed a recently attested workspace after the directory disappears", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
@@ -217,7 +254,7 @@ describe("ensureAgentWorkspace", () => {
const state = await fs.readFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
await fs.rm(tempDir, { recursive: true, force: true });
- await fs.mkdir(path.join(tempDir, WORKSPACE_STATE_PATH_SEGMENTS[0]), { recursive: true });
+ await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await fs.writeFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), state);
@@ -527,19 +564,18 @@ describe("ensureAgentWorkspace", () => {
it("migrates legacy onboardingCompletedAt markers to setupCompletedAt", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
- await fs.mkdir(path.join(tempDir, ".openclaw"), { recursive: true });
- await fs.writeFile(
- path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
- JSON.stringify({
- version: 1,
- onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
- }),
- );
+ await writeLegacyWorkspaceState(tempDir, {
+ version: 1,
+ onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
+ });
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toBe("2026-03-15T02:30:00.000Z");
+ await expect(
+ fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
+ ).resolves.toBeUndefined();
const persisted = await fs.readFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
"utf-8",
diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts
index 3517b4df8ec2..1d48d01ab9ee 100644
--- a/src/agents/workspace.ts
+++ b/src/agents/workspace.ts
@@ -36,8 +36,9 @@ export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME;
-const WORKSPACE_STATE_DIRNAME = ".openclaw";
-const WORKSPACE_STATE_FILENAME = "workspace-state.json";
+const LEGACY_WORKSPACE_STATE_DIRNAME = ".openclaw";
+const LEGACY_WORKSPACE_STATE_FILENAME = "workspace-state.json";
+const WORKSPACE_STATE_FILENAME = "openclaw-workspace-state.json";
const WORKSPACE_STATE_VERSION = 1;
const WORKSPACE_ATTESTATION_SUFFIX = ".attested";
const WORKSPACE_ATTESTATION_DIRNAME = "workspace-attestations";
@@ -305,7 +306,11 @@ async function hasSkipBootstrapWorkspaceContentEvidence(dir: string): Promise {
+function hasWorkspaceSetupStateMarker(state: WorkspaceSetupState): boolean {
+ return Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
+}
+
+function needsWorkspaceSetupStateRewrite(raw: string, state: WorkspaceSetupState): boolean {
+ return (
+ raw.includes('"onboardingCompletedAt"') &&
+ !raw.includes('"setupCompletedAt"') &&
+ Boolean(state.setupCompletedAt)
+ );
+}
+
+async function readWorkspaceSetupStateFile(statePath: string): Promise<{
+ raw: string;
+ state: WorkspaceSetupState;
+} | null> {
try {
const raw = await fs.readFile(statePath, "utf-8");
const parsed = parseWorkspaceSetupState(raw);
- if (
- opts?.persistLegacyMigration &&
- parsed &&
- raw.includes('"onboardingCompletedAt"') &&
- !raw.includes('"setupCompletedAt"') &&
- parsed.setupCompletedAt
- ) {
- await writeWorkspaceSetupState(statePath, parsed);
- }
- return parsed ?? { version: WORKSPACE_STATE_VERSION };
+ return parsed ? { raw, state: parsed } : null;
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") {
throw err;
}
- return {
- version: WORKSPACE_STATE_VERSION,
- };
+ return null;
}
}
-async function readWorkspaceSetupStateForDir(dir: string): Promise {
- const statePath = resolveWorkspaceStatePath(resolveUserPath(dir));
- return await readWorkspaceSetupState(statePath);
+async function readWorkspaceSetupStateForDir(
+ dir: string,
+ opts?: { persistLegacyMigration?: boolean },
+): Promise {
+ const resolvedDir = resolveUserPath(dir);
+ const statePath = resolveWorkspaceStatePath(resolvedDir);
+ const canonical = await readWorkspaceSetupStateFile(statePath);
+ if (canonical) {
+ if (
+ opts?.persistLegacyMigration &&
+ needsWorkspaceSetupStateRewrite(canonical.raw, canonical.state)
+ ) {
+ await writeWorkspaceSetupState(statePath, canonical.state);
+ }
+ return canonical.state;
+ }
+
+ const legacyStatePath = resolveLegacyWorkspaceStatePath(resolvedDir);
+ let legacy: Awaited>;
+ try {
+ legacy = await readWorkspaceSetupStateFile(legacyStatePath);
+ } catch {
+ // Legacy state lived under a dot directory that some workspaces reject.
+ // Treat inaccessible legacy metadata as absent so current setup can proceed.
+ legacy = null;
+ }
+ if (!legacy) {
+ return { version: WORKSPACE_STATE_VERSION };
+ }
+ if (opts?.persistLegacyMigration && hasWorkspaceSetupStateMarker(legacy.state)) {
+ await writeWorkspaceSetupState(statePath, legacy.state);
+ }
+ return legacy.state;
}
export async function isWorkspaceSetupCompleted(dir: string): Promise {
@@ -712,8 +752,7 @@ export async function resolveWorkspaceBootstrapStatus(
dir: string,
): Promise<"pending" | "complete"> {
const resolvedDir = resolveUserPath(dir);
- const statePath = resolveWorkspaceStatePath(resolvedDir);
- const state = await readWorkspaceSetupState(statePath);
+ const state = await readWorkspaceSetupStateForDir(resolvedDir);
if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) {
return "complete";
}
@@ -735,7 +774,7 @@ export async function reconcileWorkspaceBootstrapCompletion(
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const bootstrapPath = path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME);
- const state = await readWorkspaceSetupState(statePath, {
+ const state = await readWorkspaceSetupStateForDir(resolvedDir, {
persistLegacyMigration: true,
});
return await reconcileWorkspaceBootstrapCompletionState({
@@ -753,7 +792,7 @@ async function writeWorkspaceSetupState(
await replaceFileAtomic({
filePath: statePath,
content: `${JSON.stringify(state, null, 2)}\n`,
- tempPrefix: ".workspace-state",
+ tempPrefix: WORKSPACE_STATE_FILENAME,
});
}
@@ -885,10 +924,10 @@ export async function ensureAgentWorkspace(params?: {
if (recentAttestationPath && !isBrandNewWorkspace) {
const bootstrapExists = await pathExists(bootstrapPath);
- const state = await readWorkspaceSetupState(statePath, {
+ const state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
- const hasSetupState = Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
+ const hasSetupState = hasWorkspaceSetupStateMarker(state);
const hasCustomizedRequiredBootstrap = await workspaceRequiredBootstrapLooksCustomized(dir, {
attestationPath: recentAttestationPath,
});
@@ -933,7 +972,7 @@ export async function ensureAgentWorkspace(params?: {
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
}
- let state = await readWorkspaceSetupState(statePath, {
+ let state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
let stateDirty = false;
From d2439d2f7dfbc90387da8d4b82dc34f051e4f17a Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:30:27 +0800
Subject: [PATCH 16/24] fix(onboard): skip Homebrew prompt on unsupported
platforms (#93521)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
---
CHANGELOG.md | 1 +
src/commands/onboard-skills.test.ts | 60 +++++++++++++++++++++--------
src/commands/onboard-skills.ts | 8 +++-
3 files changed, 52 insertions(+), 17 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df5aff694fa8..6e13fdbb1258 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
### Fixes
+- Onboarding/skills: show the Homebrew install recommendation only on macOS and Linux, so FreeBSD and other unsupported platforms no longer get a misleading brew prompt. Fixes #68893; carries forward #68894, #68910, #68941, #68943, #69002, and #69545. Thanks @yurivict, @Sanjays2402, @Eruditi, @JustInCache, @nnish16, and @Mlightsnow.
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
- iMessage: normalize leading NUL sent-message echo prefixes while preserving interior NUL bytes and the leading attributedBody marker handling from #73942. Carries forward #63581. Thanks @drvoss.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
diff --git a/src/commands/onboard-skills.test.ts b/src/commands/onboard-skills.test.ts
index 905c5e909b50..050da74e197c 100644
--- a/src/commands/onboard-skills.test.ts
+++ b/src/commands/onboard-skills.test.ts
@@ -1,5 +1,5 @@
// Onboard skills tests cover skill setup prompts, package manager config, and skip behavior.
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
@@ -143,16 +143,30 @@ const runtime: RuntimeEnv = {
}) as RuntimeEnv["exit"],
};
+const supportsHomebrewPrompt = process.platform === "darwin" || process.platform === "linux";
+
+async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise {
+ const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
+ Object.defineProperty(process, "platform", {
+ configurable: true,
+ value: platform,
+ });
+ try {
+ return await fn();
+ } finally {
+ Object.defineProperty(process, "platform", originalPlatformDescriptor);
+ }
+}
+
describe("setupSkills", () => {
- afterEach(() => {
+ beforeEach(() => {
+ vi.clearAllMocks();
mocks.isContainerEnvironment.mockReset();
mocks.resolveBrewExecutable.mockReset();
});
it("hides brew-only installs in Linux containers when brew is missing", async () => {
- const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
- Object.defineProperty(process, "platform", { value: "linux", configurable: true });
- try {
+ await withPlatform("linux", async () => {
mockMissingBrewStatus([
createBundledSkill({
name: "video-frames",
@@ -173,15 +187,11 @@ describe("setupSkills", () => {
expect(
notes.find((n) => n.message.includes("No missing skill dependencies to install")),
).toBeUndefined();
- } finally {
- Object.defineProperty(process, "platform", originalPlatformDescriptor);
- }
+ });
});
it("keeps brew-only installs visible when Linuxbrew is resolved off PATH", async () => {
- const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
- Object.defineProperty(process, "platform", { value: "linux", configurable: true });
- try {
+ await withPlatform("linux", async () => {
mockMissingBrewStatus([
createBundledSkill({
name: "video-frames",
@@ -202,13 +212,11 @@ describe("setupSkills", () => {
);
expect(notes.find((n) => n.title === "Container skill installs")).toBeUndefined();
expect(notes.find((n) => n.title === "Homebrew recommended")).toBeUndefined();
- } finally {
- Object.defineProperty(process, "platform", originalPlatformDescriptor);
- }
+ });
});
it("does not recommend Homebrew when user skips installing brew-backed deps", async () => {
- if (process.platform === "win32") {
+ if (!supportsHomebrewPrompt) {
return;
}
@@ -247,7 +255,7 @@ describe("setupSkills", () => {
});
it("recommends Homebrew when user selects a brew-backed install and brew is missing", async () => {
- if (process.platform === "win32") {
+ if (!supportsHomebrewPrompt) {
return;
}
@@ -279,4 +287,24 @@ describe("setupSkills", () => {
expect(emptyStateNote?.message).toContain("openclaw skills list --verbose");
expect(emptyStateNote?.message).toContain("openclaw skills check");
});
+
+ it("does not recommend Homebrew on FreeBSD", async () => {
+ await withPlatform("freebsd", async () => {
+ mockMissingBrewStatus([
+ createBundledSkill({
+ name: "video-frames",
+ description: "ffmpeg",
+ bins: ["ffmpeg"],
+ installLabel: "Install ffmpeg (brew)",
+ }),
+ ]);
+
+ const { prompter, notes } = createPrompter({ multiselect: ["video-frames"] });
+ await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter);
+
+ const brewNote = notes.find((n) => n.title === "Homebrew recommended");
+ expect(brewNote).toBeUndefined();
+ expect(mocks.detectBinary).not.toHaveBeenCalledWith("brew");
+ });
+ });
});
diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts
index 0b8a00834e22..0db3e5a60907 100644
--- a/src/commands/onboard-skills.ts
+++ b/src/commands/onboard-skills.ts
@@ -16,6 +16,12 @@ import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js";
+const HOMEBREW_PROMPT_PLATFORMS = new Set(["darwin", "linux"]);
+
+function supportsHomebrewPrompt(platform: NodeJS.Platform): boolean {
+ return HOMEBREW_PROMPT_PLATFORMS.has(platform);
+}
+
function summarizeInstallFailure(message: string): string | undefined {
const cleaned = message.replace(/^Install failed(?:\s*\([^)]*\))?\s*:?\s*/i, "").trim();
if (!cleaned) {
@@ -145,7 +151,7 @@ export async function setupSkills(
.filter((item): item is (typeof installable)[number] => Boolean(item));
const needsBrewPrompt =
- process.platform !== "win32" &&
+ supportsHomebrewPrompt(process.platform) &&
selectedSkills.some((skill) => skill.install.some((option) => option.kind === "brew")) &&
!(await detectBrewOnce());
From e46bcb834faa94aac4f038c5f69cf0ab26533874 Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:30:40 +0800
Subject: [PATCH 17/24] fix(feishu): send post mentions as native at elements
(#93522)
* fix(feishu): use native at elements for blue @mention rendering
* fix(clownfish): address review for ghcrawl-156842-autonomous-smoke (1)
Co-authored-by: gavin-ali <223589024+gavin-ali@users.noreply.github.com>
Co-authored-by: Yizuki_Ame <104178195+YizukiAme@users.noreply.github.com>
Co-authored-by: Pnant <73925474+Panniantong@users.noreply.github.com>
---------
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Pnant <73925474+Panniantong@users.noreply.github.com>
---
extensions/feishu/src/bot.ts | 2 +
.../feishu/src/reply-dispatcher.test.ts | 15 ++--
extensions/feishu/src/reply-dispatcher.ts | 8 +-
extensions/feishu/src/send.test.ts | 90 ++++++++++++++++++-
extensions/feishu/src/send.ts | 59 ++++++++----
5 files changed, 149 insertions(+), 25 deletions(-)
diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts
index e7180b53944b..8053f472c78d 100644
--- a/extensions/feishu/src/bot.ts
+++ b/extensions/feishu/src/bot.ts
@@ -1602,6 +1602,7 @@ export async function handleFeishuMessage(params: {
threadReply,
accountId: account.accountId,
identity,
+ mentionTargets: ctx.mentionTargets,
messageCreateTimeMs,
sessionKey: agentSessionKey,
});
@@ -1779,6 +1780,7 @@ export async function handleFeishuMessage(params: {
threadReply,
accountId: account.accountId,
identity,
+ mentionTargets: ctx.mentionTargets,
messageCreateTimeMs,
sessionKey: route.sessionKey,
});
diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts
index 446c06311f4e..ab3d57ff9bb8 100644
--- a/extensions/feishu/src/reply-dispatcher.test.ts
+++ b/extensions/feishu/src/reply-dispatcher.test.ts
@@ -549,18 +549,23 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
- it("does not attach automatic mentions to non-streaming plain text replies", async () => {
+ it("passes mention-forward targets to non-streaming plain text replies without rewriting body text", async () => {
useNonStreamingAutoAccount();
const { options } = createDispatcherHarness({
replyToMessageId: "om_msg",
+ mentionTargets: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
- await options.deliver({ text: "plain text" }, { kind: "final" });
+ await options.deliver(
+ { text: 'plain text Body User' },
+ { kind: "final" },
+ );
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
- expect(firstMockArg(sendMessageFeishuMock, "send message params")).not.toHaveProperty(
- "mentions",
- );
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
+ text: 'plain text Body User',
+ mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
+ });
});
it("does not attach automatic mentions to card replies", async () => {
diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts
index e4356ef7cf0a..9fffb3a1211f 100644
--- a/extensions/feishu/src/reply-dispatcher.ts
+++ b/extensions/feishu/src/reply-dispatcher.ts
@@ -15,6 +15,7 @@ import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
+import type { MentionTarget } from "./mention-target.types.js";
import {
createReplyPrefixContext,
type ClawdbotConfig,
@@ -129,6 +130,7 @@ type CreateFeishuReplyDispatcherParams = {
rootId?: string;
accountId?: string;
identity?: OutboundIdentity;
+ mentionTargets?: MentionTarget[];
/** Epoch ms when the inbound message was created. Used to suppress typing
* indicators on old/replayed messages after context compaction (#30418). */
messageCreateTimeMs?: number;
@@ -149,6 +151,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
rootId,
accountId,
identity,
+ mentionTargets,
} = params;
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
const typingTargetMessageId = explicitTypingTargetMessageId?.trim() || replyToMessageId;
@@ -743,7 +746,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text,
useCard: false,
infoKind: info?.kind,
- sendChunk: async ({ chunk }) => {
+ sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: chatId,
@@ -752,6 +755,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
+ ...(info?.kind === "final" && isFirst && mentionTargets?.length
+ ? { mentions: mentionTargets }
+ : {}),
});
},
});
diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts
index aa87de539242..904fe8eb38a6 100644
--- a/extensions/feishu/src/send.test.ts
+++ b/extensions/feishu/src/send.test.ts
@@ -1,7 +1,7 @@
// Feishu tests cover send plugin behavior.
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
-import { buildMarkdownCard } from "./send.js";
+import { buildFeishuPostMessagePayload, buildMarkdownCard } from "./send.js";
const {
mockConvertMarkdownTables,
@@ -64,6 +64,49 @@ let listFeishuThreadMessages: typeof import("./send.js").listFeishuThreadMessage
let resolveFeishuCardTemplate: typeof import("./send.js").resolveFeishuCardTemplate;
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
+describe("buildFeishuPostMessagePayload", () => {
+ it("prepends structured mention targets as native post at elements", () => {
+ const payload = buildFeishuPostMessagePayload({
+ messageText: "hello **world**",
+ mentions: [
+ { openId: "ou_alice", name: "Alice", key: "@_user_1" },
+ { openId: " ou_bob ", name: " Bob ", key: "@_user_2" },
+ ],
+ });
+
+ expect(payload.msgType).toBe("post");
+ expect(JSON.parse(payload.content)).toEqual({
+ zh_cn: {
+ content: [
+ [
+ { tag: "at", user_id: "ou_alice", user_name: "Alice" },
+ { tag: "at", user_id: "ou_bob", user_name: "Bob" },
+ { tag: "md", text: "hello **world**" },
+ ],
+ ],
+ },
+ });
+ });
+
+ it("leaves body-supplied at tags literal in the markdown element", () => {
+ const payload = buildFeishuPostMessagePayload({
+ messageText: 'please keep Body User literal',
+ mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
+ });
+
+ expect(JSON.parse(payload.content)).toEqual({
+ zh_cn: {
+ content: [
+ [
+ { tag: "at", user_id: "ou_target", user_name: "Target User" },
+ { tag: "md", text: 'please keep Body User literal' },
+ ],
+ ],
+ },
+ });
+ });
+});
+
describe("getMessageFeishu", () => {
beforeAll(async () => {
({
@@ -173,6 +216,51 @@ describe("getMessageFeishu", () => {
});
});
+ it("sends automatic mentions as native post elements without rewriting body text", async () => {
+ const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_mentions" } });
+ mockCreateFeishuClient.mockReturnValue({
+ im: {
+ message: {
+ create,
+ reply: vi.fn(),
+ get: mockClientGet,
+ list: mockClientList,
+ patch: mockClientPatch,
+ },
+ },
+ });
+
+ const result = await sendMessageFeishu({
+ cfg: {} as ClawdbotConfig,
+ to: "oc_send",
+ text: 'body Body User',
+ mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
+ });
+
+ expect(mockConvertMarkdownTables).toHaveBeenCalledWith(
+ 'body Body User',
+ "preserve",
+ );
+ expect(create).toHaveBeenCalledWith({
+ params: { receive_id_type: "chat_id" },
+ data: {
+ receive_id: "oc_send",
+ msg_type: "post",
+ content: JSON.stringify({
+ zh_cn: {
+ content: [
+ [
+ { tag: "at", user_id: "ou_target", user_name: "Target User" },
+ { tag: "md", text: 'body Body User' },
+ ],
+ ],
+ },
+ }),
+ },
+ });
+ expect(result).toEqual({ messageId: "om_mentions", chatId: "oc_send" });
+ });
+
it("extracts text content from interactive card elements", async () => {
mockClientGet.mockResolvedValueOnce({
code: 0,
diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts
index 95fbf90d6760..45309bf16231 100644
--- a/extensions/feishu/src/send.ts
+++ b/extensions/feishu/src/send.ts
@@ -12,7 +12,7 @@ import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { requestFeishuApi } from "./comment-shared.js";
import type { MentionTarget } from "./mention-target.types.js";
-import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
+import { buildMentionedCardContent } from "./mention.js";
import { parsePostContent } from "./post.js";
import {
assertFeishuMessageApiSuccess,
@@ -546,22 +546,50 @@ export type SendFeishuMessageParams = {
accountId?: string;
};
-export function buildFeishuPostMessagePayload(params: { messageText: string }): {
+type FeishuPostMessageElement =
+ | { tag: "at"; user_id: string; user_name?: string }
+ | { tag: "md"; text: string };
+
+function buildFeishuPostMentionElements(mentions?: MentionTarget[]): FeishuPostMessageElement[] {
+ if (!mentions?.length) {
+ return [];
+ }
+
+ const elements: FeishuPostMessageElement[] = [];
+ for (const mention of mentions) {
+ const userId = mention.openId.trim();
+ if (!userId) {
+ continue;
+ }
+ const userName = mention.name.trim();
+ elements.push({
+ tag: "at",
+ user_id: userId,
+ ...(userName ? { user_name: userName } : {}),
+ });
+ }
+ return elements;
+}
+
+export function buildFeishuPostMessagePayload(params: {
+ messageText: string;
+ mentions?: MentionTarget[];
+}): {
content: string;
msgType: string;
} {
- const { messageText } = params;
+ const { messageText, mentions } = params;
+ const content: FeishuPostMessageElement[] = [
+ ...buildFeishuPostMentionElements(mentions),
+ {
+ tag: "md",
+ text: messageText,
+ },
+ ];
return {
content: JSON.stringify({
zh_cn: {
- content: [
- [
- {
- tag: "md",
- text: messageText,
- },
- ],
- ],
+ content: [content],
},
}),
msgType: "post",
@@ -587,14 +615,9 @@ export async function sendMessageFeishu(
channel: "feishu",
});
- // Build message content (with @mention support)
- let rawText = text ?? "";
- if (mentions && mentions.length > 0) {
- rawText = buildMentionedMessage(mentions, rawText);
- }
- const messageText = convertMarkdownTables(rawText, tableMode);
+ const messageText = convertMarkdownTables(text ?? "", tableMode);
- const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
+ const { content, msgType } = buildFeishuPostMessagePayload({ messageText, mentions });
const directParams = { receiveId, receiveIdType, content, msgType };
return sendReplyOrFallbackDirect(client, {
From 53da30dd98d7e73b2634962f6aadeed0f35e252e Mon Sep 17 00:00:00 2001
From: Vincent Koc
Date: Tue, 16 Jun 2026 14:31:11 +0800
Subject: [PATCH 18/24] fix(e2e): repair omitted Codex platform package
---
scripts/e2e/parallels/linux-smoke.ts | 9 ++-
scripts/e2e/parallels/macos-smoke.ts | 7 ++
scripts/e2e/parallels/npm-update-scripts.ts | 22 +++++-
scripts/e2e/parallels/plugin-isolation.ts | 78 +++++++++++++++++++++
scripts/e2e/parallels/windows-smoke.ts | 10 ++-
test/scripts/parallels-smoke-model.test.ts | 18 +++++
6 files changed, 139 insertions(+), 5 deletions(-)
diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts
index ccfe507fc956..a0feb515159b 100755
--- a/scripts/e2e/parallels/linux-smoke.ts
+++ b/scripts/e2e/parallels/linux-smoke.ts
@@ -14,6 +14,7 @@ import {
parseProvider,
readPositiveIntEnv,
modelProviderConfigBatchJson,
+ posixCodexPlatformPackageRepairFunction,
posixProviderOnlyPluginIsolationScript,
repoRoot,
resolveParallelsModelTimeoutSeconds,
@@ -741,7 +742,8 @@ rm -f "$provider_config_batch"`);
this.restrictAgentTurnPlugins();
this.prepareAgentWorkspace();
this.guestBash(
- `agent_ok=false
+ `${posixCodexPlatformPackageRepairFunction()}
+agent_ok=false
for attempt in 1 2; do
session_id="parallels-linux-smoke"
if [ "$attempt" -gt 1 ]; then session_id="parallels-linux-smoke-retry-$attempt"; fi
@@ -755,6 +757,11 @@ for attempt in 1 2; do
set -e
cat "$output_file"
if [ "$rc" -ne 0 ]; then
+ if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
+ rm -f "$output_file"
+ echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
+ continue
+ fi
rm -f "$output_file"
exit "$rc"
fi
diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts
index 4a71e9caf99a..72513b7ce335 100755
--- a/scripts/e2e/parallels/macos-smoke.ts
+++ b/scripts/e2e/parallels/macos-smoke.ts
@@ -16,6 +16,7 @@ import {
parseMode,
parseProvider,
modelProviderConfigBatchJson,
+ posixCodexPlatformPackageRepairFunction,
posixProviderOnlyPluginIsolationScript,
parsePositiveInt,
readPositiveIntEnv,
@@ -1108,6 +1109,7 @@ rm -f "$provider_config_batch"`);
this.restrictAgentTurnPlugins();
this.guestSh(
`${posixAgentWorkspaceScript("Parallels macOS smoke test assistant.")}
+${posixCodexPlatformPackageRepairFunction()}
agent_ok=false
for attempt in 1 2; do
session_id="parallels-macos-smoke"
@@ -1122,6 +1124,11 @@ for attempt in 1 2; do
set -e
cat "$output_file"
if [ "$rc" -ne 0 ]; then
+ if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
+ rm -f "$output_file"
+ echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
+ continue
+ fi
rm -f "$output_file"
exit "$rc"
fi
diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts
index 639c1e8296da..60e67ab7b39f 100644
--- a/scripts/e2e/parallels/npm-update-scripts.ts
+++ b/scripts/e2e/parallels/npm-update-scripts.ts
@@ -1,7 +1,11 @@
// Npm Update Scripts script supports OpenClaw repository automation.
import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import { shellQuote } from "./host-command.ts";
-import { posixProviderOnlyPluginIsolationScript } from "./plugin-isolation.ts";
+import {
+ posixCodexPlatformPackageRepairFunction,
+ posixProviderOnlyPluginIsolationScript,
+ windowsCodexPlatformPackageRepairFunction,
+} from "./plugin-isolation.ts";
import {
psSingleQuote,
windowsAgentTurnConfigPatchScript,
@@ -72,6 +76,7 @@ function posixAssertAgentOkScript(command: string, input: NpmUpdateScriptInput,
fallbackPluginId: input.auth.modelId.split("/", 1)[0] || "openai",
modelId: input.auth.modelId,
})}
+${posixCodexPlatformPackageRepairFunction()}
agent_ok=false
for attempt in 1 2; do
session_id=${shellQuote(sessionId)}
@@ -84,6 +89,11 @@ for attempt in 1 2; do
set -e
print_log_tail "$output_file"
if [ "$rc" -ne 0 ]; then
+ if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
+ rm -f "$output_file"
+ echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
+ continue
+ fi
rm -f "$output_file"
exit "$rc"
fi
@@ -138,6 +148,7 @@ Wait-OpenClawGateway`;
function windowsAssertAgentOkScript(input: NpmUpdateScriptInput): string {
return `${windowsAgentTurnConfigPatchScript(input.auth.modelId)}
+${windowsCodexPlatformPackageRepairFunction()}
$sessionPath = Join-Path $env:USERPROFILE '.openclaw\\agents\\main\\sessions\\parallels-npm-update-windows.jsonl'
Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue
${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
@@ -149,16 +160,21 @@ for ($attempt = 1; $attempt -le 2; $attempt++) {
$sessionPath = Join-Path $sessionsDir "$sessionId.jsonl"
Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue
$output = Invoke-OpenClaw agent --local --agent main --session-id $sessionId --model ${psSingleQuote(input.auth.modelId)} --message 'Reply with exact ASCII text OK only.' --thinking off --timeout ${resolveParallelsModelTimeoutSeconds("windows")} --json 2>&1
+ $agentExitCode = $LASTEXITCODE
if ($null -ne $output) { $output | ForEach-Object { $_ } }
- if ($LASTEXITCODE -ne 0) { throw "agent failed with exit code $LASTEXITCODE" }
- if (($output | Out-String) -match '"finalAssistant(Raw|Visible)Text":\\s*"OK"') {
+ if ($agentExitCode -eq 0 -and ($output | Out-String) -match '"finalAssistant(Raw|Visible)Text":\\s*"OK"') {
$agentOk = $true
break
}
+ if ($agentExitCode -ne 0 -and $attempt -lt 2 -and (Repair-MissingCodexPlatformPackage -Output $output)) {
+ Write-Host "agent turn attempt $attempt hit a missing Codex platform package; retrying"
+ continue
+ }
if ($attempt -lt 2) {
Write-Host "agent turn attempt $attempt finished without OK response; retrying"
Start-Sleep -Seconds 3
}
+ if ($agentExitCode -ne 0) { throw "agent failed with exit code $agentExitCode" }
}
if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`;
}
diff --git a/scripts/e2e/parallels/plugin-isolation.ts b/scripts/e2e/parallels/plugin-isolation.ts
index dbf7da57d103..d2642d3f6996 100644
--- a/scripts/e2e/parallels/plugin-isolation.ts
+++ b/scripts/e2e/parallels/plugin-isolation.ts
@@ -9,6 +9,84 @@ interface PluginIsolationOptions {
nodeCommand?: string;
}
+export function posixCodexPlatformPackageRepairFunction(): string {
+ return `repair_missing_codex_platform_package() {
+ output_file="$1"
+ grep -F 'Missing optional dependency @openai/codex-' "$output_file" >/dev/null 2>&1 || return 1
+ state_home="\${OPENCLAW_PARALLELS_HOME:-\${HOME:-}}"
+ codex_manifest=""
+ for candidate in "$state_home"/.openclaw/npm/projects/*/node_modules/@openclaw/codex/package.json; do
+ [ -f "$candidate" ] || continue
+ codex_manifest="$candidate"
+ break
+ done
+ if [ -z "$codex_manifest" ]; then
+ echo "codex-platform-repair: managed Codex project not found" >&2
+ return 1
+ fi
+ project_root="\${codex_manifest%/node_modules/@openclaw/codex/package.json}"
+ cache_dir="$(mktemp -d "\${TMPDIR:-/tmp}/openclaw-npm-cache.XXXXXX")"
+ echo "codex-platform-repair: retrying managed npm install once with a fresh cache" >&2
+ repair_rc=0
+ (
+ cd "$project_root"
+ NPM_CONFIG_CACHE="$cache_dir" npm_config_cache="$cache_dir" npm install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund
+ ) || repair_rc=$?
+ rm -rf "$cache_dir"
+ if [ "$repair_rc" -ne 0 ]; then
+ echo "codex-platform-repair: npm install failed with exit code $repair_rc" >&2
+ return "$repair_rc"
+ fi
+ echo "codex-platform-repair: managed npm install completed" >&2
+}`;
+}
+
+export function windowsCodexPlatformPackageRepairFunction(): string {
+ return String.raw`function Repair-MissingCodexPlatformPackage {
+ param([object[]] $Output)
+ $outputText = $Output | Out-String
+ if ($outputText -notmatch [regex]::Escape('Missing optional dependency @openai/codex-')) {
+ return $false
+ }
+ $projectsRoot = Join-Path $env:USERPROFILE '.openclaw\npm\projects'
+ $codexManifest = Get-ChildItem -Path $projectsRoot -Filter package.json -File -Recurse -ErrorAction SilentlyContinue |
+ Where-Object { $_.FullName -match 'node_modules[\\/]@openclaw[\\/]codex[\\/]package\.json$' } |
+ Select-Object -First 1
+ if (-not $codexManifest) {
+ Write-Warning 'codex-platform-repair: managed Codex project not found'
+ return $false
+ }
+ $projectRoot = $codexManifest.Directory.Parent.Parent.Parent.FullName
+ $cacheDir = Join-Path ([System.IO.Path]::GetTempPath()) ('openclaw-npm-cache-' + [guid]::NewGuid().ToString('N'))
+ $oldUpperCache = [Environment]::GetEnvironmentVariable('NPM_CONFIG_CACHE', 'Process')
+ $oldLowerCache = [Environment]::GetEnvironmentVariable('npm_config_cache', 'Process')
+ $pushedLocation = $false
+ $repairExit = 1
+ try {
+ New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
+ [Environment]::SetEnvironmentVariable('NPM_CONFIG_CACHE', $cacheDir, 'Process')
+ [Environment]::SetEnvironmentVariable('npm_config_cache', $cacheDir, 'Process')
+ Push-Location $projectRoot
+ $pushedLocation = $true
+ Write-Host 'codex-platform-repair: retrying managed npm install once with a fresh cache'
+ $repairOutput = & npm.cmd install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund 2>&1
+ $repairExit = $LASTEXITCODE
+ if ($null -ne $repairOutput) { $repairOutput | ForEach-Object { Write-Host $_ } }
+ } finally {
+ if ($pushedLocation) { Pop-Location }
+ [Environment]::SetEnvironmentVariable('NPM_CONFIG_CACHE', $oldUpperCache, 'Process')
+ [Environment]::SetEnvironmentVariable('npm_config_cache', $oldLowerCache, 'Process')
+ Remove-Item $cacheDir -Force -Recurse -ErrorAction SilentlyContinue
+ }
+ if ($repairExit -ne 0) {
+ Write-Warning "codex-platform-repair: npm install failed with exit code $repairExit"
+ return $false
+ }
+ Write-Host 'codex-platform-repair: managed npm install completed'
+ return $true
+}`;
+}
+
export function providerOnlyPluginId(modelId: string, fallbackPluginId: string): string {
return providerIdFromModelId(modelId) || fallbackPluginId;
}
diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts
index f6bf8218f0d8..a5baa34379b4 100755
--- a/scripts/e2e/parallels/windows-smoke.ts
+++ b/scripts/e2e/parallels/windows-smoke.ts
@@ -33,7 +33,10 @@ import { runWindowsBackgroundPowerShell, WindowsGuest } from "./guest-transports
import { startHostServer } from "./host-server.ts";
import { ensureVmRunning } from "./parallels-vm.ts";
import { PhaseRunner } from "./phase-runner.ts";
-import { windowsProviderOnlyPluginIsolationScript } from "./plugin-isolation.ts";
+import {
+ windowsCodexPlatformPackageRepairFunction,
+ windowsProviderOnlyPluginIsolationScript,
+} from "./plugin-isolation.ts";
import {
psSingleQuote,
windowsAgentTurnConfigPatchScript,
@@ -725,6 +728,7 @@ $PSNativeCommandUseErrorActionPreference = $false
${windowsPortableGitPathScript}
${windowsAgentTurnConfigPatchScript(this.auth.modelId)}
${windowsAgentWorkspaceScript("Parallels Windows smoke test assistant.")}
+${windowsCodexPlatformPackageRepairFunction()}
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
$agentOk = $false
for ($attempt = 1; $attempt -le 2; $attempt++) {
@@ -754,6 +758,10 @@ for ($attempt = 1; $attempt -le 2; $attempt++) {
$agentOk = $true
break
}
+ if ($agentExitCode -ne 0 -and $attempt -lt 2 -and (Repair-MissingCodexPlatformPackage -Output $output)) {
+ Write-Host "agent turn attempt $attempt hit a missing Codex platform package; retrying"
+ continue
+ }
if ($attempt -lt 2) {
Write-Host "agent turn attempt $attempt failed or finished without OK response; retrying"
Start-Sleep -Seconds 3
diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts
index b7b0ec6bdb59..445fce8767cc 100644
--- a/test/scripts/parallels-smoke-model.test.ts
+++ b/test/scripts/parallels-smoke-model.test.ts
@@ -42,6 +42,10 @@ import { parseArgs as parseLinuxSmokeArgs } from "../../scripts/e2e/parallels/li
import { parseArgs as parseMacosSmokeArgs } from "../../scripts/e2e/parallels/macos-smoke.ts";
import { parseArgs as parseNpmUpdateSmokeArgs } from "../../scripts/e2e/parallels/npm-update-smoke.ts";
import { PhaseRunner } from "../../scripts/e2e/parallels/phase-runner.ts";
+import {
+ posixCodexPlatformPackageRepairFunction,
+ windowsCodexPlatformPackageRepairFunction,
+} from "../../scripts/e2e/parallels/plugin-isolation.ts";
import { parseArgs as parseWindowsSmokeArgs } from "../../scripts/e2e/parallels/windows-smoke.ts";
import { withEnv } from "../../src/test-utils/env.js";
import { spawnNodeEvalSync } from "../../src/test-utils/node-process.js";
@@ -275,6 +279,20 @@ describe("Parallels smoke model selection", () => {
}
});
+ it("repairs only the exact missing Codex platform package failure with a fresh npm cache", () => {
+ const posixRepair = posixCodexPlatformPackageRepairFunction();
+ const windowsRepair = windowsCodexPlatformPackageRepairFunction();
+
+ for (const repair of [posixRepair, windowsRepair]) {
+ expect(repair).toContain("Missing optional dependency @openai/codex-");
+ expect(repair).toContain("NPM_CONFIG_CACHE");
+ expect(repair).toContain("--ignore-scripts");
+ expect(repair).toContain("codex-platform-repair: managed npm install completed");
+ }
+ expect(posixRepair).toContain("repair_missing_codex_platform_package");
+ expect(windowsRepair).toContain("Repair-MissingCodexPlatformPackage");
+ });
+
it("writes full model ids as config map keys in provider batches", () => {
const batch = JSON.parse(modelProviderConfigBatchJson("openai/gpt-5.5", "windows")) as Array<{
path: string;
From d7cebdc215bfe987975726b1107f52143a7ef9eb Mon Sep 17 00:00:00 2001
From: Harjoth Khara
Date: Mon, 15 Jun 2026 23:32:38 -0700
Subject: [PATCH 19/24] fix(gateway): rotate already-stale generated transcript
filename on /reset (#93496)
Merged via squash.
Prepared head SHA: 6ae356c34aa3cf8cd1f4a0b86f46f0a4ca5a4906
Co-authored-by: harjothkhara <48686985+harjothkhara@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
---
...drain-active-sessions-for-shutdown.test.ts | 1 +
.../server.sessions.reset-models.test.ts | 50 +++++++++++++++++++
src/gateway/session-reset-service.ts | 15 ++++--
src/gateway/session-transcript-files.fs.ts | 2 +-
4 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/src/gateway/drain-active-sessions-for-shutdown.test.ts b/src/gateway/drain-active-sessions-for-shutdown.test.ts
index a5024cbec3df..1b3ca1a473b1 100644
--- a/src/gateway/drain-active-sessions-for-shutdown.test.ts
+++ b/src/gateway/drain-active-sessions-for-shutdown.test.ts
@@ -29,6 +29,7 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
}));
vi.mock("./session-transcript-files.fs.js", () => ({
+ extractGeneratedTranscriptSessionId: vi.fn(() => undefined),
resolveStableSessionEndTranscript: vi.fn(() => ({
sessionFile: undefined,
transcriptArchived: false,
diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts
index b9cd20522fb5..db5feb668359 100644
--- a/src/gateway/server.sessions.reset-models.test.ts
+++ b/src/gateway/server.sessions.reset-models.test.ts
@@ -372,6 +372,56 @@ test("sessions.reset rotates generated topic transcript files with the new sessi
expect(path.basename(persistedEntry?.sessionFile ?? "")).toBe(`${nextSessionId}-topic-456.jsonl`);
});
+test("sessions.reset rotates an already-stale generated transcript file to the new session id", async () => {
+ const { dir, storePath } = await createSessionStoreDir();
+ // Post-upgrade state: the stored sessionFile still embeds an OLDER generated id
+ // that no longer matches the entry's logical sessionId, so rotation must key off
+ // the file's embedded id rather than the current sessionId (issue #77770).
+ const staleFileSessionId = "11111111-1111-4111-8111-111111111111";
+ const currentSessionId = "22222222-2222-4222-8222-222222222222";
+ const staleSessionFile = path.join(dir, `${staleFileSessionId}.jsonl`);
+ await fs.writeFile(staleSessionFile, `${JSON.stringify({ role: "user", content: "old" })}\n`);
+
+ await writeSessionStore({
+ entries: {
+ main: sessionStoreEntry(currentSessionId, {
+ sessionFile: staleSessionFile,
+ }),
+ },
+ });
+
+ const reset = await directSessionReq<{
+ ok: true;
+ key: string;
+ entry: {
+ sessionId: string;
+ sessionFile?: string;
+ };
+ }>("sessions.reset", { key: "main" });
+
+ expect(reset.ok).toBe(true);
+ const nextSessionId = reset.payload?.entry.sessionId;
+ const nextSessionFile = reset.payload?.entry.sessionFile;
+ if (!nextSessionId || !nextSessionFile) {
+ throw new Error("expected reset session id and file");
+ }
+ expect(nextSessionId).not.toBe(currentSessionId);
+ // The new session must adopt the new session id, not keep the stale generated name.
+ expect(path.basename(nextSessionFile)).toBe(`${nextSessionId}.jsonl`);
+ expect(path.basename(nextSessionFile)).not.toBe(`${staleFileSessionId}.jsonl`);
+
+ const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
+ string,
+ {
+ sessionId?: string;
+ sessionFile?: string;
+ }
+ >;
+ const persistedEntry = store["agent:main:main"];
+ expect(persistedEntry?.sessionId).toBe(nextSessionId);
+ expect(path.basename(persistedEntry?.sessionFile ?? "")).toBe(`${nextSessionId}.jsonl`);
+});
+
test("sessions.reset preserves legacy explicit model overrides without modelOverrideSource", async () => {
await expectMainResetModelFields({
defaultPrimary: "openai/gpt-test-a",
diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts
index 19e890452dd1..3c683632e3bd 100644
--- a/src/gateway/session-reset-service.ts
+++ b/src/gateway/session-reset-service.ts
@@ -61,6 +61,7 @@ import {
import { findDirectChildSessionsForParent } from "./session-child-sessions.js";
import {
archiveSessionTranscriptsDetailed,
+ extractGeneratedTranscriptSessionId,
resolveStableSessionEndTranscript,
type ArchivedSessionTranscript,
} from "./session-transcript-files.fs.js";
@@ -82,12 +83,16 @@ function resolveResetSessionFile(params: {
agentId: string;
}): string {
const currentEntry = params.currentEntry;
- // Preserve explicit session-file placement across reset while swapping the
- // embedded session id, so linked runtimes keep writing beside old transcripts.
- const rewrittenSessionFile = currentEntry?.sessionId
+ // Rotate generated transcript names by the file's *embedded* id, not the logical
+ // session id: a post-upgrade sessionFile can embed a stale id, so keying off
+ // currentEntry.sessionId would orphan the reset session on the old file. Explicit
+ // custom placements have no embedded id and stay preserved.
+ const rotationPreviousSessionId =
+ extractGeneratedTranscriptSessionId(currentEntry?.sessionFile) ?? currentEntry?.sessionId;
+ const rewrittenSessionFile = rotationPreviousSessionId
? rewriteSessionFileForNewSessionId({
- sessionFile: currentEntry.sessionFile,
- previousSessionId: currentEntry.sessionId,
+ sessionFile: currentEntry?.sessionFile,
+ previousSessionId: rotationPreviousSessionId,
nextSessionId: params.nextSessionId,
})
: undefined;
diff --git a/src/gateway/session-transcript-files.fs.ts b/src/gateway/session-transcript-files.fs.ts
index 4847d7032d3d..4aff2470470c 100644
--- a/src/gateway/session-transcript-files.fs.ts
+++ b/src/gateway/session-transcript-files.fs.ts
@@ -106,7 +106,7 @@ function classifySessionTranscriptCandidate(
return transcriptSessionId === sessionId ? "current" : "stale";
}
-function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined {
+export function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined {
const trimmed = sessionFile?.trim();
if (!trimmed) {
return undefined;
From e06f5f2edc6fdaf6a236d4dfe8c5d7f3a5ca1e1b Mon Sep 17 00:00:00 2001
From: Bhargav Chinta
Date: Tue, 16 Jun 2026 12:03:25 +0530
Subject: [PATCH 20/24] fix(cron): preserve aborted isolated-run failure
(#93471)
Merged via squash.
Prepared head SHA: dfbba9aa403e7d474d6bd014fe08b59d48ef2c6a
Co-authored-by: BhargavSatya <24696554+BhargavSatya@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
---
.../run.meta-error-status.test.ts | 37 ++++++++++++++++++-
src/cron/isolated-agent/run.ts | 20 ++++++++++
2 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/src/cron/isolated-agent/run.meta-error-status.test.ts b/src/cron/isolated-agent/run.meta-error-status.test.ts
index e0b7d16870b7..a6b6fb8b980f 100644
--- a/src/cron/isolated-agent/run.meta-error-status.test.ts
+++ b/src/cron/isolated-agent/run.meta-error-status.test.ts
@@ -2,10 +2,15 @@
import { describe, expect, it } from "vitest";
import { CommandLaneTaskTimeoutError } from "../../process/command-queue.js";
import {
+ makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
-import { loadRunCronIsolatedAgentTurn, runWithModelFallbackMock } from "./run.test-harness.js";
+import {
+ cleanupDirectCronSessionMock,
+ loadRunCronIsolatedAgentTurn,
+ runWithModelFallbackMock,
+} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
@@ -54,6 +59,36 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
expect(result.outputText).toBe("cron isolated run failed: retry limit exceeded");
});
+ it("marks an aborted embedded agent run without a run-level error as a cron error", async () => {
+ runWithModelFallbackMock.mockResolvedValueOnce({
+ result: {
+ payloads: [],
+ meta: {
+ aborted: true,
+ agentMeta: { usage: { input: 0, output: 0 } },
+ },
+ },
+ provider: "openai",
+ model: "gpt-5.4",
+ attempts: [],
+ });
+
+ const result = await runCronIsolatedAgentTurn(
+ makeIsolatedAgentTurnParams({
+ job: makeIsolatedAgentTurnJob({ deleteAfterRun: true }),
+ }),
+ );
+
+ expect(result.status).toBe("error");
+ expect(result.error).toBe("cron isolated agent run aborted");
+ expect(cleanupDirectCronSessionMock).toHaveBeenCalledWith({
+ job: expect.objectContaining({ deleteAfterRun: true }),
+ agentSessionKey: "agent:default:cron:test",
+ sessionId: "test-session-id",
+ retireReason: "cron-delete-after-run-aborted",
+ });
+ });
+
it("surfaces cron timeout result when the cron-nested lane watchdog fires", async () => {
runWithModelFallbackMock.mockRejectedValueOnce(
new CommandLaneTaskTimeoutError("cron-nested", 330_000),
diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts
index 7fc1b507923d..9e45e4046d8b 100644
--- a/src/cron/isolated-agent/run.ts
+++ b/src/cron/isolated-agent/run.ts
@@ -1078,6 +1078,26 @@ async function finalizeCronRun(params: {
})
).preferFinalAssistantVisibleText,
});
+ if (finalRunResult.meta?.aborted === true && !cronPayloadOutcome.hasFatalErrorPayload) {
+ const metaErrorMessage = normalizeOptionalString(finalRunResult.meta.error?.message);
+ const error = metaErrorMessage ?? "cron isolated agent run aborted";
+ const { cleanupDirectCronSession } = await loadCronDeliveryRuntime();
+ await cleanupDirectCronSession({
+ job: prepared.input.job,
+ agentSessionKey: prepared.agentSessionKey,
+ sessionId: prepared.currentRunSessionId(),
+ retireReason: "cron-delete-after-run-aborted",
+ });
+ return prepared.withRunSession({
+ status: "error",
+ error,
+ diagnostics: mergeCronRunDiagnostics(
+ createCronRunDiagnosticsFromAgentResult(finalRunResult, { finalStatus: "error" }),
+ createCronRunDiagnosticsFromError("agent-run", error),
+ ),
+ ...telemetry,
+ });
+ }
const {
synthesizedText,
deliveryPayloads,
From 6aff1e8f9ea451f2d00e7840ddfa14a7c1e5a5bf Mon Sep 17 00:00:00 2001
From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Date: Mon, 15 Jun 2026 23:34:01 -0700
Subject: [PATCH 21/24] fix(memory): report skipped QMD embedding probe
(#93473)
Merged via squash.
Prepared head SHA: eea1ba563b958dbd5632fec36223d9ee76da1f5d
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
---
extensions/memory-core/src/cli.runtime.ts | 10 ++++--
extensions/memory-core/src/cli.test.ts | 36 +++++++++++++++++++
.../src/memory/qmd-manager.test.ts | 4 +--
.../memory-core/src/memory/qmd-manager.ts | 3 ++
4 files changed, 49 insertions(+), 4 deletions(-)
diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts
index 3dbcb3104947..625551296701 100644
--- a/extensions/memory-core/src/cli.runtime.ts
+++ b/extensions/memory-core/src/cli.runtime.ts
@@ -884,8 +884,14 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
- const state = embeddingProbe.ok ? "ready" : "unavailable";
- const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
+ const state =
+ embeddingProbe.ok && embeddingProbe.checked === false
+ ? "skipped"
+ : embeddingProbe.ok
+ ? "ready"
+ : "unavailable";
+ const stateColor =
+ state === "skipped" ? theme.muted : embeddingProbe.ok ? theme.success : theme.warn;
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
if (embeddingProbe.error) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts
index cf137e15aed5..f37b6471c97e 100644
--- a/extensions/memory-core/src/cli.test.ts
+++ b/extensions/memory-core/src/cli.test.ts
@@ -677,6 +677,42 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
+ it("does not report qmd lexical search mode as embedding unavailable", async () => {
+ const close = vi.fn(async () => {});
+ const probeVectorStoreAvailability = vi.fn(async () => true);
+ const probeVectorAvailability = vi.fn(async () => false);
+ const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true, checked: false }));
+ mockManager({
+ probeVectorStoreAvailability,
+ probeVectorAvailability,
+ probeEmbeddingAvailability,
+ status: () =>
+ makeMemoryStatus({
+ backend: "qmd",
+ provider: "qmd",
+ model: "qmd",
+ requestedProvider: "qmd",
+ vector: {
+ enabled: false,
+ semanticAvailable: false,
+ available: false,
+ },
+ }),
+ close,
+ });
+
+ const log = spyRuntimeLogs(defaultRuntime);
+ await runMemoryCli(["status", "--deep"]);
+
+ expect(probeVectorStoreAvailability).not.toHaveBeenCalled();
+ expect(probeVectorAvailability).toHaveBeenCalled();
+ expect(probeEmbeddingAvailability).toHaveBeenCalled();
+ expectLogged(log, "Vector: disabled");
+ expectLogged(log, "Embeddings: skipped");
+ expectNotLogged(log, "Embeddings error:");
+ expect(close).toHaveBeenCalled();
+ });
+
it("prints recall-store audit details during status", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts
index 52f468aa8b69..5a041a979f4c 100644
--- a/extensions/memory-core/src/memory/qmd-manager.test.ts
+++ b/extensions/memory-core/src/memory/qmd-manager.test.ts
@@ -6062,8 +6062,8 @@ describe("QmdMemoryManager", () => {
await expect(manager.probeVectorAvailability()).resolves.toBe(false);
await expect(manager.probeEmbeddingAvailability()).resolves.toEqual({
- ok: false,
- error: "QMD semantic vectors are unavailable",
+ ok: true,
+ checked: false,
});
expect(spawnMock.mock.calls.length).toBe(baselineCalls);
expect(manager.status().vector).toEqual({
diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts
index 0f04106a7e11..0e2b422c2f22 100644
--- a/extensions/memory-core/src/memory/qmd-manager.ts
+++ b/extensions/memory-core/src/memory/qmd-manager.ts
@@ -1566,6 +1566,9 @@ export class QmdMemoryManager implements MemorySearchManager {
}
async probeEmbeddingAvailability(): Promise {
+ if (!qmdUsesVectors(this.qmd.searchMode)) {
+ return { ok: true, checked: false };
+ }
const ok = await this.probeVectorAvailability();
return {
ok,
From b037280ea9882debdfb125ccb566e5101d33ad7b Mon Sep 17 00:00:00 2001
From: zhaoqj2016
Date: Tue, 16 Jun 2026 14:34:40 +0800
Subject: [PATCH 22/24] fix(ui): preserve CJK IME composition (#93498)
Merged via squash.
Prepared head SHA: c84ef0bdf50d3d34eb5b96786fe29489ee4434b8
Co-authored-by: Zhaoqj2016 <21196165+Zhaoqj2016@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
---
ui/src/ui/views/chat.test.ts | 103 +++++++++++++++++++++++++++++++++++
ui/src/ui/views/chat.ts | 41 ++++++++++++--
2 files changed, 138 insertions(+), 6 deletions(-)
diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts
index 0649d3f8d47e..4562e385a853 100644
--- a/ui/src/ui/views/chat.test.ts
+++ b/ui/src/ui/views/chat.test.ts
@@ -1633,6 +1633,109 @@ describe("chat voice controls", () => {
});
});
+describe("chat composer IME composition", () => {
+ it("defers draft sync while IME composition is active", () => {
+ const onDraftChange = vi.fn();
+ const onRequestUpdate = vi.fn();
+ const container = renderChatView({ onDraftChange, onRequestUpdate });
+ const textarea = requireElement(
+ container,
+ ".agent-chat__composer-combobox > textarea",
+ "composer textarea",
+ ) as HTMLTextAreaElement;
+
+ textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
+ textarea.value = "dangqian";
+ textarea.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true }));
+
+ expect(onDraftChange).not.toHaveBeenCalled();
+ expect(onRequestUpdate).not.toHaveBeenCalled();
+
+ textarea.value = "当前";
+ textarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
+
+ expect(onDraftChange).toHaveBeenCalledTimes(1);
+ expect(onDraftChange).toHaveBeenLastCalledWith("当前");
+ });
+
+ it("preserves composing text across host rerenders with stale draft props", () => {
+ const onDraftChange = vi.fn();
+ const onRequestUpdate = vi.fn();
+ const container = document.createElement("div");
+ const props = createChatProps({ draft: "", onDraftChange, onRequestUpdate });
+
+ render(renderChat(props), container);
+ const textarea = requireElement(
+ container,
+ ".agent-chat__composer-combobox > textarea",
+ "composer textarea",
+ ) as HTMLTextAreaElement;
+
+ textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
+ textarea.value = "dangqian";
+ textarea.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true }));
+
+ expect(onDraftChange).not.toHaveBeenCalled();
+ expect(onRequestUpdate).not.toHaveBeenCalled();
+
+ render(renderChat({ ...props, draft: "" }), container);
+
+ expect(container.querySelector("textarea")?.value).toBe("dangqian");
+
+ const rerenderedTextarea = requireElement(
+ container,
+ ".agent-chat__composer-combobox > textarea",
+ "composer textarea",
+ ) as HTMLTextAreaElement;
+ rerenderedTextarea.value = "当前";
+ rerenderedTextarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
+
+ expect(onDraftChange).toHaveBeenCalledTimes(1);
+ expect(onDraftChange).toHaveBeenLastCalledWith("当前");
+ });
+
+ it("leaves keyboard events to the browser while IME composition is active", () => {
+ const onHistoryKeydown = vi.fn(() => ({
+ handled: true,
+ preventDefault: true,
+ restoreCaret: null,
+ decision: "handled:history-up" as const,
+ historyNavigationActiveBefore: false,
+ historyNavigationActiveAfter: false,
+ selectionStart: 0,
+ selectionEnd: 0,
+ valueLength: 0,
+ }));
+ const onSend = vi.fn();
+ const container = renderChatView({ onHistoryKeydown, onSend });
+ const textarea = requireElement(
+ container,
+ ".agent-chat__composer-combobox > textarea",
+ "composer textarea",
+ ) as HTMLTextAreaElement;
+
+ textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
+ textarea.value = "dangqian";
+ const enterEvent = new KeyboardEvent("keydown", {
+ key: "Enter",
+ bubbles: true,
+ cancelable: true,
+ });
+ const arrowEvent = new KeyboardEvent("keydown", {
+ key: "ArrowUp",
+ bubbles: true,
+ cancelable: true,
+ });
+ textarea.dispatchEvent(enterEvent);
+ textarea.dispatchEvent(arrowEvent);
+
+ expect(enterEvent.defaultPrevented).toBe(false);
+ expect(arrowEvent.defaultPrevented).toBe(false);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onHistoryKeydown).not.toHaveBeenCalled();
+ });
+});
+
describe("chat slash menu accessibility", () => {
function inputDraft(container: HTMLElement, value: string) {
const textarea = container.querySelector("textarea");
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index 24865737b0ab..587ff82fd598 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -473,6 +473,7 @@ interface ChatEphemeralState {
searchOpen: boolean;
searchQuery: string;
pinnedExpanded: boolean;
+ composerComposing: boolean;
historyRenderSessionKey: string | null;
historyRenderMessagesRef: unknown[] | null;
historyRenderMessageCount: number;
@@ -499,6 +500,7 @@ function createChatEphemeralState(): ChatEphemeralState {
searchOpen: false,
searchQuery: "",
pinnedExpanded: false,
+ composerComposing: false,
historyRenderSessionKey: null,
historyRenderMessagesRef: null,
historyRenderMessageCount: 0,
@@ -2229,6 +2231,12 @@ export function renderChat(props: ChatProps) {
};
const handleKeyDown = (e: KeyboardEvent) => {
+ // IME navigation keys belong to the browser; downstream handlers can
+ // prevent them or commit the in-progress composition as a host draft.
+ if (vs.composerComposing || e.isComposing || e.keyCode === 229) {
+ return;
+ }
+
// Slash menu navigation — arg mode
if (vs.slashMenuOpen && vs.slashMenuMode === "args" && vs.slashMenuArgItems.length > 0) {
const len = vs.slashMenuArgItems.length;
@@ -2336,9 +2344,6 @@ export function renderChat(props: ChatProps) {
// Send on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
- if (e.isComposing || e.keyCode === 229) {
- return;
- }
if (!props.connected) {
return;
}
@@ -2352,16 +2357,36 @@ export function renderChat(props: ChatProps) {
}
};
- const handleInput = (e: Event) => {
- const target = e.target as HTMLTextAreaElement;
+ const syncComposerValue = (
+ target: HTMLTextAreaElement,
+ options: { forceCommit?: boolean } = {},
+ ) => {
adjustTextareaHeight(target);
draftMirror.value = target.value;
const hostDraftNeeded = isBusy || showAbortableUi || props.queue.length > 0;
- if (hostDraftNeeded || target.value.startsWith("/") || hasVisibleSlashMenuState()) {
+ if (
+ options.forceCommit ||
+ hostDraftNeeded ||
+ target.value.startsWith("/") ||
+ hasVisibleSlashMenuState()
+ ) {
commitComposerDraft(props, target.value);
}
updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value);
};
+ const handleInput = (e: InputEvent) => {
+ const target = e.target as HTMLTextAreaElement;
+ if (vs.composerComposing || e.isComposing) {
+ adjustTextareaHeight(target);
+ draftMirror.value = target.value;
+ return;
+ }
+ syncComposerValue(target);
+ };
+ const handleCompositionEnd = (e: CompositionEvent) => {
+ vs.composerComposing = false;
+ syncComposerValue(e.target as HTMLTextAreaElement, { forceCommit: true });
+ };
const handleBlur = (e: FocusEvent) => {
const target = e.target as HTMLTextAreaElement;
commitComposerDraft(props, target.value);
@@ -2450,6 +2475,10 @@ export function renderChat(props: ChatProps) {
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
@keydown=${handleKeyDown}
@input=${handleInput}
+ @compositionstart=${() => {
+ vs.composerComposing = true;
+ }}
+ @compositionend=${handleCompositionEnd}
@blur=${handleBlur}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${placeholder}
From 840cfd69cd47ecdbbdbef15092421055fc435dc3 Mon Sep 17 00:00:00 2001
From: Martin Kessler
Date: Mon, 15 Jun 2026 23:35:14 -0700
Subject: [PATCH 23/24] fix(telegram): bind bot mentions to assistant identity
(#93088)
* fix(telegram): bind bot mentions to assistant identity
* fix(telegram): satisfy context payload mention typing
* refactor(telegram): carry mention facts as one context object
* test(telegram): use neutral bot handle fixture
* fix(ci): terminate heartbeat command groups
* fix(ci): preserve heartbeat shell functions
* fix(telegram): project effective mention facts
* fix(telegram): keep mention identity portable
* test(telegram): align mention facts mock
---------
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
---
docs/channels/telegram.md | 4 ++
.../telegram/src/bot-message-context.body.ts | 48 +++++++++++++++++++
...e-context.implicit-mention.test-support.ts | 2 +
.../src/bot-message-context.session.ts | 8 ++++
.../bot-message-context.sticker-media.test.ts | 7 +++
.../telegram/src/bot-message-context.ts | 3 +-
.../src/bot.create-telegram-bot.test.ts | 31 ++++++++++++
src/auto-reply/reply/groups.test.ts | 27 +++++++++++
src/auto-reply/reply/groups.ts | 6 +++
src/channels/inbound-event/context.test.ts | 8 ++++
src/channels/inbound-event/context.ts | 5 ++
src/channels/turn/types.ts | 10 +++-
12 files changed, 157 insertions(+), 2 deletions(-)
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index 070abfb3639a..8e4da6f079ef 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -111,6 +111,10 @@ After a successful startup, OpenClaw caches the bot identity in the state direct
## Access control and activation
+### Group bot identity
+
+In Telegram groups and forum topics, an explicit mention of the configured bot handle (for example `@my_bot`) is treated as addressing the selected OpenClaw agent, even when the agent persona name differs from the Telegram username. The group silence policy still applies to unrelated group traffic, but the bot handle itself is not considered "someone else."
+
`channels.telegram.dmPolicy` controls direct message access:
diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts
index 747857ceba14..16738a4c6553 100644
--- a/extensions/telegram/src/bot-message-context.body.ts
+++ b/extensions/telegram/src/bot-message-context.body.ts
@@ -6,6 +6,7 @@ import {
logInboundDrop,
matchesMentionWithExplicit,
resolveInboundMentionDecision,
+ type BuildChannelInboundEventContextParams,
type BuildMentionRegexesOptions,
type NormalizedLocation,
} from "openclaw/plugin-sdk/channel-inbound";
@@ -50,6 +51,9 @@ import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
type StickerVisionRuntime = typeof import("./sticker-vision.runtime.js");
type MediaUnderstandingRuntime = typeof import("./media-understanding.runtime.js");
+type TelegramMentionFacts = NonNullable<
+ NonNullable["mentions"]
+>;
let stickerVisionRuntimePromise: Promise | undefined;
let mediaUnderstandingRuntimePromise: Promise | undefined;
@@ -70,6 +74,7 @@ export type TelegramInboundBodyResult = {
historyKey?: string;
commandAuthorized: boolean;
effectiveWasMentioned: boolean;
+ mentionFacts: TelegramMentionFacts;
canDetectMention: boolean;
shouldBypassMention: boolean;
hasControlCommand: boolean;
@@ -120,6 +125,39 @@ function formatSavedMediaPlaceholder(allMedia: TelegramMediaRef[]): string | und
return ` (${allMedia.length} attachments)`;
}
+function resolveTelegramMentionFacts(params: {
+ canDetectMention: boolean;
+ effectiveWasMentioned: boolean;
+ explicitlyMentionedBot: boolean;
+ computedWasMentioned: boolean;
+ implicitMentionKinds: TelegramMentionFacts["implicitMentionKinds"];
+ requireMention: boolean;
+ shouldBypassMention: boolean;
+ shouldSkip: boolean;
+}): TelegramMentionFacts {
+ let mentionSource: TelegramMentionFacts["mentionSource"];
+ if (params.explicitlyMentionedBot) {
+ mentionSource = "explicit_bot";
+ } else if (params.computedWasMentioned) {
+ mentionSource = "mention_pattern";
+ } else if (params.implicitMentionKinds && params.implicitMentionKinds.length > 0) {
+ mentionSource = "implicit_thread";
+ } else if (params.shouldBypassMention) {
+ mentionSource = "command_bypass";
+ }
+
+ return {
+ canDetectMention: params.canDetectMention,
+ wasMentioned: params.effectiveWasMentioned,
+ explicitlyMentionedBot: params.explicitlyMentionedBot,
+ mentionSource,
+ implicitMentionKinds: params.implicitMentionKinds,
+ effectiveWasMentioned: params.effectiveWasMentioned,
+ requireMention: params.requireMention,
+ shouldSkip: params.shouldSkip,
+ };
+}
+
async function resolveStickerVisionSupport(params: {
cfg: OpenClawConfig;
agentId?: string;
@@ -442,6 +480,16 @@ export async function resolveTelegramInboundBody(params: {
historyKey,
commandAuthorized,
effectiveWasMentioned,
+ mentionFacts: resolveTelegramMentionFacts({
+ canDetectMention,
+ effectiveWasMentioned,
+ explicitlyMentionedBot: explicitlyMentioned,
+ computedWasMentioned,
+ implicitMentionKinds,
+ requireMention: Boolean(requireMention),
+ shouldBypassMention: mentionDecision.shouldBypassMention,
+ shouldSkip: mentionDecision.shouldSkip,
+ }),
canDetectMention,
shouldBypassMention: mentionDecision.shouldBypassMention,
hasControlCommand: hasControlCommandInMessage,
diff --git a/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts b/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts
index f323569f0de9..25ae9e2bf2d0 100644
--- a/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts
+++ b/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts
@@ -106,6 +106,8 @@ describe("buildTelegramMessageContext implicitMention forum service messages", (
// Real bot reply → implicitMention fires → message is NOT skipped.
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
+ expect(ctx?.ctxPayload?.MentionSource).toBe("implicit_thread");
+ expect(ctx?.ctxPayload?.ImplicitMentionKinds).toEqual(["reply_to_bot"]);
});
it("DOES trigger implicitMention for bot media messages with caption", async () => {
diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts
index a73c7d908367..184fb04afbdc 100644
--- a/extensions/telegram/src/bot-message-context.session.ts
+++ b/extensions/telegram/src/bot-message-context.session.ts
@@ -1,6 +1,7 @@
// Telegram plugin module implements bot message context.session behavior.
import path from "node:path";
import {
+ type BuildChannelInboundEventContextParams,
type BuildChannelInboundEventContextAsyncParams,
type BuiltChannelInboundEventContext,
classifyChannelInboundEvent,
@@ -33,6 +34,10 @@ import type {
TelegramMessageContextSessionRuntimeOverrides,
TelegramPromptContextEntry,
} from "./bot-message-context.types.js";
+
+type TelegramMentionFacts = NonNullable<
+ NonNullable["mentions"]
+>;
import {
buildGroupLabel,
buildSenderLabel,
@@ -220,6 +225,7 @@ export async function buildTelegramInboundContextPayload(params: {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
effectiveWasMentioned: boolean;
+ mentionFacts: TelegramMentionFacts;
hasControlCommand: boolean;
stickerCacheHit?: boolean;
audioTranscribedMediaIndex?: number;
@@ -270,6 +276,7 @@ export async function buildTelegramInboundContextPayload(params: {
groupConfig,
topicConfig,
effectiveWasMentioned,
+ mentionFacts,
hasControlCommand,
stickerCacheHit,
audioTranscribedMediaIndex,
@@ -544,6 +551,7 @@ export async function buildTelegramInboundContextPayload(params: {
commands: {
authorized: commandAuthorized,
},
+ mentions: mentionFacts,
},
command:
commandSource === "native"
diff --git a/extensions/telegram/src/bot-message-context.sticker-media.test.ts b/extensions/telegram/src/bot-message-context.sticker-media.test.ts
index 626d66c98daf..5b1f5c96589c 100644
--- a/extensions/telegram/src/bot-message-context.sticker-media.test.ts
+++ b/extensions/telegram/src/bot-message-context.sticker-media.test.ts
@@ -11,6 +11,13 @@ const inboundBodyMock = vi.hoisted(() =>
historyKey: undefined,
commandAuthorized: false,
effectiveWasMentioned: false,
+ mentionFacts: {
+ canDetectMention: true,
+ wasMentioned: false,
+ effectiveWasMentioned: false,
+ requireMention: false,
+ shouldSkip: false,
+ },
canDetectMention: true,
shouldBypassMention: false,
hasControlCommand: false,
diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts
index 840471d6bcfd..19a6ed99b027 100644
--- a/extensions/telegram/src/bot-message-context.ts
+++ b/extensions/telegram/src/bot-message-context.ts
@@ -477,7 +477,7 @@ export const buildTelegramMessageContext = async ({
groupConfig,
topicConfig,
providerMentionPatterns: cfg.channels?.telegram?.accounts?.[account.accountId]?.mentionPatterns,
- requireMention,
+ requireMention: Boolean(requireMention),
options,
groupHistories,
historyLimit,
@@ -533,6 +533,7 @@ export const buildTelegramMessageContext = async ({
groupConfig,
topicConfig,
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
+ mentionFacts: bodyResult.mentionFacts,
hasControlCommand: bodyResult.hasControlCommand,
stickerCacheHit: bodyResult.stickerCacheHit,
...(bodyResult.audioTranscribedMediaIndex !== undefined
diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts
index 9961ae23d658..73662e3fbd8d 100644
--- a/extensions/telegram/src/bot.create-telegram-bot.test.ts
+++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts
@@ -3758,6 +3758,37 @@ describe("createTelegramBot", () => {
}
}
});
+ it("marks explicit Telegram bot-handle mentions in the inbound context", async () => {
+ resetHarnessSpies();
+ loadConfig.mockReturnValue({
+ channels: {
+ telegram: {
+ groupPolicy: "open",
+ groups: { "*": { requireMention: true } },
+ },
+ },
+ });
+
+ await dispatchMessage({
+ message: {
+ chat: { id: 7, type: "group", title: "Test Group" },
+ text: "@openclaw_bot status",
+ entities: [{ type: "mention", offset: 0, length: "@openclaw_bot".length }],
+ date: 1736380800,
+ message_id: 4,
+ from: { id: 9, first_name: "Ada" },
+ },
+ me: { id: 999, username: "openclaw_bot" },
+ });
+
+ expect(replySpy).toHaveBeenCalledTimes(1);
+ const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0];
+ expect(payload.WasMentioned).toBe(true);
+ expect(payload.ExplicitlyMentionedBot).toBe(true);
+ expect(payload.MentionSource).toBe("explicit_bot");
+ expect(payload.BotUsername).toBe("openclaw_bot");
+ });
+
it("keeps group envelope headers stable (sender identity is separate)", async () => {
resetHarnessSpies();
diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts
index 98014ad9c88c..400ec2011c89 100644
--- a/src/auto-reply/reply/groups.test.ts
+++ b/src/auto-reply/reply/groups.test.ts
@@ -120,6 +120,33 @@ describe("group runtime loading", () => {
expect(disallowed).not.toContain("Never say that you are staying quiet");
});
+ it("binds an explicitly mentioned channel handle to the current assistant identity", () => {
+ const context = groups.buildGroupChatContext({
+ sessionCtx: {
+ ChatType: "group",
+ Provider: "telegram",
+ BotUsername: "SirPinchALotBot",
+ ExplicitlyMentionedBot: true,
+ },
+ silentToken: "NO_REPLY",
+ silentReplyPolicy: "allow",
+ });
+
+ expect(context).toContain("explicitly mentions your channel identity @SirPinchALotBot");
+ expect(context).toContain("Treat that mention as addressed to you");
+
+ const notExplicit = groups.buildGroupChatContext({
+ sessionCtx: {
+ ChatType: "group",
+ Provider: "telegram",
+ BotUsername: "kesslerAIBot",
+ },
+ silentToken: "NO_REPLY",
+ silentReplyPolicy: "allow",
+ });
+ expect(notExplicit).not.toContain("channel identity @kesslerAIBot");
+ });
+
it("marks non-visible assistant replies silent for groups with silence allowed", () => {
expect(
groups.resolveGroupSilentReplyBehavior({
diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts
index d1672d0873b8..934ce0fd6680 100644
--- a/src/auto-reply/reply/groups.ts
+++ b/src/auto-reply/reply/groups.ts
@@ -231,9 +231,15 @@ export function buildGroupChatContext(params: {
const providerLabel = resolveProviderLabel(params.sessionCtx.Provider);
const provider = normalizeOptionalLowercaseString(params.sessionCtx.Provider);
const messageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only";
+ const botUsername = normalizeOptionalString(params.sessionCtx.BotUsername);
const lines: string[] = [];
lines.push(`You are in a ${providerLabel} group chat.`);
+ if (params.sessionCtx.ExplicitlyMentionedBot === true && botUsername) {
+ lines.push(
+ `The incoming message explicitly mentions your channel identity @${botUsername}. Treat that mention as addressed to you, even if your persona name differs.`,
+ );
+ }
if (messageToolOnly) {
lines.push(
"Normal final replies are private and are not automatically sent to this group chat. To post visible output here, use the message tool with action=send; the target defaults to this group chat.",
diff --git a/src/channels/inbound-event/context.test.ts b/src/channels/inbound-event/context.test.ts
index 1d89b722bc18..316330a37157 100644
--- a/src/channels/inbound-event/context.test.ts
+++ b/src/channels/inbound-event/context.test.ts
@@ -86,6 +86,10 @@ describe("buildChannelInboundEventContext", () => {
mentions: {
canDetectMention: true,
wasMentioned: true,
+ explicitlyMentionedBot: true,
+ mentionSource: "explicit_bot",
+ mentionedUserIds: ["bot-1"],
+ implicitMentionKinds: ["reply_to_bot"],
},
},
commandTurn: {
@@ -161,6 +165,10 @@ describe("buildChannelInboundEventContext", () => {
Provider: "test-provider",
Surface: "test-surface",
WasMentioned: true,
+ ExplicitlyMentionedBot: true,
+ MentionedUserIds: ["bot-1"],
+ ImplicitMentionKinds: ["reply_to_bot"],
+ MentionSource: "explicit_bot",
CommandAuthorized: true,
CommandSource: "text",
CommandTurn: {
diff --git a/src/channels/inbound-event/context.ts b/src/channels/inbound-event/context.ts
index 0cd442ca5978..36d86b4107a4 100644
--- a/src/channels/inbound-event/context.ts
+++ b/src/channels/inbound-event/context.ts
@@ -503,6 +503,11 @@ export function buildChannelInboundEventContext(
Provider: params.provider ?? params.channel,
Surface: params.surface ?? params.provider ?? params.channel,
WasMentioned: params.access?.mentions?.wasMentioned,
+ ExplicitlyMentionedBot: params.access?.mentions?.explicitlyMentionedBot,
+ MentionedUserIds: params.access?.mentions?.mentionedUserIds,
+ MentionedSubteamIds: params.access?.mentions?.mentionedSubteamIds,
+ ImplicitMentionKinds: params.access?.mentions?.implicitMentionKinds,
+ MentionSource: params.access?.mentions?.mentionSource,
CommandAuthorized: resolveAccessFactsCommandAuthorized(params.access) === true,
CommandTurn: commandTurn,
MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId,
diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts
index 469acfd939fb..ebc47d010932 100644
--- a/src/channels/turn/types.ts
+++ b/src/channels/turn/types.ts
@@ -8,7 +8,11 @@ import type { HistoryEntry, HistoryMediaEntry } from "../../auto-reply/reply/his
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import type { ReplyDispatcherWithTypingOptions } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js";
-import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js";
+import type {
+ FinalizedMsgContext,
+ MentionSource,
+ MsgContext,
+} from "../../auto-reply/templating.js";
import type { GroupKeyResolution } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type {
@@ -179,6 +183,10 @@ export type AccessFacts = {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
+ explicitlyMentionedBot?: boolean;
+ mentionedUserIds?: string[];
+ mentionedSubteamIds?: string[];
+ mentionSource?: MentionSource;
implicitMentionKinds?: Array<
"reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native"
>;
From 4c9e7f6c6171d7224f7dc1f71783f0160c89c1a3 Mon Sep 17 00:00:00 2001
From: zengLingbiao
Date: Tue, 16 Jun 2026 14:35:58 +0800
Subject: [PATCH 24/24] fix(nodes): return screen snapshots as media (#93499)
Merged via squash.
Prepared head SHA: 6a69c5cdccd0d07e630ae2a517a5a8878555737c
Co-authored-by: zenglingbiao <290951975+zenglingbiao@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
---
.../OpenClawKit/Resources/tool-display.json | 9 ++
src/agents/tool-display-config.ts | 4 +
src/agents/tools/nodes-tool-media.ts | 53 ++++++++-
src/agents/tools/nodes-tool.test.ts | 112 ++++++++++++++++++
src/agents/tools/nodes-tool.ts | 12 +-
src/cli/nodes-camera.test.ts | 54 ++++++++-
src/cli/nodes-screen.ts | 41 +++++++
.../codex-dynamic-tools.discord-group.json | 1 +
.../codex-dynamic-tools.heartbeat-turn.json | 1 +
.../codex-dynamic-tools.telegram-direct.json | 1 +
.../discord-group-codex-message-tool.md | 8 +-
.../telegram-direct-codex-message-tool.md | 8 +-
.../telegram-heartbeat-codex-tool.md | 8 +-
13 files changed, 296 insertions(+), 16 deletions(-)
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
index 1796d95f7bd5..86d54b853e6d 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
@@ -306,6 +306,15 @@
"fps",
"screenIndex"
]
+ },
+ "screen_snapshot": {
+ "label": "screen snapshot",
+ "detailKeys": [
+ "node",
+ "nodeId",
+ "screenIndex",
+ "maxWidth"
+ ]
}
}
},
diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts
index 57b9ff78493a..ca4cc80fafde 100644
--- a/src/agents/tool-display-config.ts
+++ b/src/agents/tool-display-config.ts
@@ -218,6 +218,10 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
label: "screen record",
detailKeys: ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"],
},
+ screen_snapshot: {
+ label: "screen snapshot",
+ detailKeys: ["node", "nodeId", "screenIndex", "maxWidth"],
+ },
},
},
cron: {
diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts
index 0eda81db7052..550ea55f3f58 100644
--- a/src/agents/tools/nodes-tool-media.ts
+++ b/src/agents/tools/nodes-tool-media.ts
@@ -16,8 +16,11 @@ import {
} from "../../cli/nodes-camera.js";
import {
parseScreenRecordPayload,
+ parseScreenSnapshotPayload,
screenRecordTempPath,
+ screenSnapshotTempPath,
writeScreenRecordToFile,
+ writeScreenSnapshotToFile,
} from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import type { ImageSanitizationLimits } from "../image-sanitization.js";
@@ -37,6 +40,7 @@ export const MEDIA_INVOKE_ACTIONS = {
"camera.clip": "camera_clip",
"photos.latest": "photos_latest",
"screen.record": "screen_record",
+ "screen.snapshot": "screen_snapshot",
// file-transfer commands: redirect to dedicated tools for better result
// formatting and media-store handling. The gateway still enforces the
// underlying node-invoke path policy for raw callers.
@@ -55,7 +59,12 @@ export const POLICY_REDIRECT_INVOKE_COMMANDS: ReadonlySet = new Set([
"file.write",
]);
-export type NodeMediaAction = "camera_snap" | "photos_latest" | "camera_clip" | "screen_record";
+export type NodeMediaAction =
+ | "camera_snap"
+ | "photos_latest"
+ | "camera_clip"
+ | "screen_record"
+ | "screen_snapshot";
const MAX_RECORDING_DURATION_MS = 300_000;
type ExecuteNodeMediaActionParams = {
@@ -78,6 +87,8 @@ export async function executeNodeMediaAction(
return await executeCameraClip(input);
case "screen_record":
return await executeScreenRecord(input);
+ case "screen_snapshot":
+ return await executeScreenSnapshot(input);
}
throw new Error("Unsupported node media action");
}
@@ -389,6 +400,46 @@ async function executeScreenRecord({
};
}
+async function executeScreenSnapshot({
+ params,
+ gatewayOpts,
+}: ExecuteNodeMediaActionParams): Promise> {
+ const node = requireString(params, "node");
+ const nodeId = await resolveNodeId(gatewayOpts, node);
+ const screenIndex = readNonNegativeIntegerParam(params, "screenIndex") ?? 0;
+ const maxWidth = readPositiveIntegerParam(params, "maxWidth");
+ const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
+ nodeId,
+ command: "screen.snapshot",
+ params: { screenIndex, maxWidth },
+ idempotencyKey: crypto.randomUUID(),
+ });
+ const payload = parseScreenSnapshotPayload(raw?.payload);
+ const normalizedFormat = normalizeLowercaseStringOrEmpty(payload.format);
+ if (normalizedFormat !== "jpg" && normalizedFormat !== "jpeg" && normalizedFormat !== "png") {
+ throw new Error(`unsupported screen.snapshot format: ${payload.format}`);
+ }
+ const ext = normalizedFormat === "png" ? "png" : "jpg";
+ const filePath =
+ typeof params.outPath === "string" && params.outPath.trim()
+ ? params.outPath.trim()
+ : screenSnapshotTempPath({ ext });
+ const written = await writeScreenSnapshotToFile(filePath, payload.base64);
+ return {
+ content: [{ type: "text", text: `FILE:${written.path}` }],
+ details: {
+ path: written.path,
+ format: payload.format,
+ screenIndex: payload.screenIndex,
+ width: payload.width,
+ height: payload.height,
+ media: {
+ mediaUrl: written.path,
+ },
+ },
+ };
+}
+
function requireString(params: Record, key: string): string {
const raw = params[key];
if (typeof raw !== "string" || raw.trim().length === 0) {
diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts
index 26c74ce3a287..48bdd7b95ca8 100644
--- a/src/agents/tools/nodes-tool.test.ts
+++ b/src/agents/tools/nodes-tool.test.ts
@@ -38,6 +38,15 @@ const screenMocks = vi.hoisted(() => ({
})),
screenRecordTempPath: vi.fn(() => "/tmp/screen-record.mp4"),
writeScreenRecordToFile: vi.fn(async () => ({ path: "/tmp/screen-record.mp4" })),
+ parseScreenSnapshotPayload: vi.fn(() => ({
+ base64: "ZmFrZQ==",
+ format: "png",
+ screenIndex: 0,
+ width: 1920,
+ height: 1080,
+ })),
+ screenSnapshotTempPath: vi.fn(() => "/tmp/screen-snapshot.png"),
+ writeScreenSnapshotToFile: vi.fn(async () => ({ path: "/tmp/screen-snapshot.png" })),
}));
vi.mock("./gateway.js", () => ({
@@ -62,6 +71,9 @@ vi.mock("../../cli/nodes-screen.js", () => ({
parseScreenRecordPayload: screenMocks.parseScreenRecordPayload,
screenRecordTempPath: screenMocks.screenRecordTempPath,
writeScreenRecordToFile: screenMocks.writeScreenRecordToFile,
+ parseScreenSnapshotPayload: screenMocks.parseScreenSnapshotPayload,
+ screenSnapshotTempPath: screenMocks.screenSnapshotTempPath,
+ writeScreenSnapshotToFile: screenMocks.writeScreenSnapshotToFile,
}));
let createNodesTool: typeof import("./nodes-tool.js").createNodesTool;
@@ -123,6 +135,9 @@ describe("createNodesTool screen_record duration guardrails", () => {
nodeUtilsMocks.resolveNode.mockClear();
screenMocks.parseScreenRecordPayload.mockClear();
screenMocks.writeScreenRecordToFile.mockClear();
+ screenMocks.parseScreenSnapshotPayload.mockClear();
+ screenMocks.screenSnapshotTempPath.mockClear();
+ screenMocks.writeScreenSnapshotToFile.mockClear();
nodesCameraMocks.cameraTempPath.mockClear();
nodesCameraMocks.parseCameraSnapPayload.mockClear();
nodesCameraMocks.writeCameraPayloadToFile.mockClear();
@@ -258,6 +273,69 @@ describe("createNodesTool screen_record duration guardrails", () => {
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
+ it("invokes screen.snapshot with validated params and returns file details", async () => {
+ gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } });
+ const tool = createNodesTool();
+
+ const result = await tool.execute("call-snapshot", {
+ action: "screen_snapshot",
+ node: "macbook",
+ screenIndex: 1,
+ maxWidth: "1200",
+ });
+
+ expect(gatewayMocks.callGatewayTool).toHaveBeenCalledTimes(1);
+ const call = gatewayMocks.callGatewayTool.mock.calls[0] as
+ | [
+ string,
+ unknown,
+ { command?: string; params?: { screenIndex?: unknown; maxWidth?: unknown } },
+ ]
+ | undefined;
+ expect(call?.[0]).toBe("node.invoke");
+ expect(call?.[2].command).toBe("screen.snapshot");
+ expect(call?.[2].params).toEqual({ screenIndex: 1, maxWidth: 1200 });
+ expect(screenMocks.parseScreenSnapshotPayload).toHaveBeenCalledWith({ ok: true });
+ expect(screenMocks.screenSnapshotTempPath).toHaveBeenCalledWith({ ext: "png" });
+ expect(screenMocks.writeScreenSnapshotToFile).toHaveBeenCalledWith(
+ "/tmp/screen-snapshot.png",
+ "ZmFrZQ==",
+ );
+ expect(result).toEqual({
+ content: [{ type: "text", text: "FILE:/tmp/screen-snapshot.png" }],
+ details: {
+ path: "/tmp/screen-snapshot.png",
+ format: "png",
+ screenIndex: 0,
+ width: 1920,
+ height: 1080,
+ media: {
+ mediaUrl: "/tmp/screen-snapshot.png",
+ },
+ },
+ });
+ });
+
+ it("rejects unsupported screen.snapshot response formats before writing", async () => {
+ gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } });
+ screenMocks.parseScreenSnapshotPayload.mockReturnValueOnce({
+ base64: "ZmFrZQ==",
+ format: "webp",
+ screenIndex: 0,
+ width: 1920,
+ height: 1080,
+ });
+ const tool = createNodesTool();
+
+ await expect(
+ tool.execute("call-snapshot", {
+ action: "screen_snapshot",
+ node: "macbook",
+ }),
+ ).rejects.toThrow("unsupported screen.snapshot format: webp");
+ expect(screenMocks.writeScreenSnapshotToFile).not.toHaveBeenCalled();
+ });
+
it("rejects the removed run action", async () => {
const tool = createNodesTool();
@@ -387,6 +465,8 @@ describe("createNodesTool screen_record duration guardrails", () => {
["photos_latest", { quality: -0.1 }, "quality must be between 0 and 1"],
["screen_record", { fps: 0 }, "fps must be greater than 0"],
["screen_record", { screenIndex: 1.5 }, "screenIndex must be a non-negative integer"],
+ ["screen_snapshot", { maxWidth: 0 }, "maxWidth must be a positive integer"],
+ ["screen_snapshot", { screenIndex: -1 }, "screenIndex must be a non-negative integer"],
])("rejects invalid %s numeric params %s", async (action, params, message) => {
const tool = createNodesTool();
@@ -561,6 +641,38 @@ describe("createNodesTool screen_record duration guardrails", () => {
);
});
+ it("blocks raw screen.snapshot invoke to prevent base64 context bloat", async () => {
+ const tool = createNodesTool();
+
+ await expect(
+ tool.execute("call-1", {
+ action: "invoke",
+ node: "macbook",
+ invokeCommand: "screen.snapshot",
+ }),
+ ).rejects.toThrow('use action="screen_snapshot"');
+ expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
+ });
+
+ it("preserves explicitly enabled raw screen.snapshot invoke", async () => {
+ gatewayMocks.callGatewayTool.mockResolvedValue({
+ payload: { format: "png", base64: "ZmFrZQ==" },
+ });
+ const tool = createNodesTool({ allowMediaInvokeCommands: true });
+
+ await tool.execute("call-1", {
+ action: "invoke",
+ node: "macbook",
+ invokeCommand: "screen.snapshot",
+ });
+
+ expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
+ "node.invoke",
+ {},
+ expect.objectContaining({ command: "screen.snapshot" }),
+ );
+ });
+
it("keeps invoke pairing guidance for scope upgrade rejections", async () => {
gatewayMocks.callGatewayTool.mockRejectedValueOnce(
new Error("scope upgrade pending approval (requestId: req-123)"),
diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts
index 519231a935e0..3380f6c01ace 100644
--- a/src/agents/tools/nodes-tool.ts
+++ b/src/agents/tools/nodes-tool.ts
@@ -39,6 +39,7 @@ const NODES_TOOL_ACTIONS = [
"camera_clip",
"photos_latest",
"screen_record",
+ "screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
@@ -97,7 +98,7 @@ const NodesToolSchema = Type.Object({
sound: Type.Optional(Type.String()),
priority: optionalStringEnum(NOTIFY_PRIORITIES),
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
- // camera_snap / camera_clip
+ // camera_snap / camera_clip / photos_latest / screen_snapshot
facing: optionalStringEnum(CAMERA_FACING, {
description: "camera_snap: front/back/both; camera_clip: front/back only.",
}),
@@ -271,6 +272,15 @@ export function createNodesTool(options?: {
imageSanitization,
});
}
+ case "screen_snapshot": {
+ return await executeNodeMediaAction({
+ action,
+ params,
+ gatewayOpts,
+ modelHasVision: options?.modelHasVision,
+ imageSanitization,
+ });
+ }
case "location_get": {
return await executeNodeCommandAction({
action,
diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts
index fccd9a6554fc..ded5b34b4d98 100644
--- a/src/cli/nodes-camera.test.ts
+++ b/src/cli/nodes-camera.test.ts
@@ -29,8 +29,11 @@ let writeCameraClipPayloadToFile: typeof import("./nodes-camera.js").writeCamera
let writeBase64ToFile: typeof import("./nodes-camera.js").writeBase64ToFile;
let writeUrlToFile: typeof import("./nodes-camera.js").writeUrlToFile;
let parseScreenRecordPayload: typeof import("./nodes-screen.js").parseScreenRecordPayload;
+let parseScreenSnapshotPayload: typeof import("./nodes-screen.js").parseScreenSnapshotPayload;
let screenRecordTempPath: typeof import("./nodes-screen.js").screenRecordTempPath;
+let screenSnapshotTempPath: typeof import("./nodes-screen.js").screenSnapshotTempPath;
let writeScreenRecordToFile: typeof import("./nodes-screen.js").writeScreenRecordToFile;
+let writeScreenSnapshotToFile: typeof import("./nodes-screen.js").writeScreenSnapshotToFile;
async function withCameraTempDir(run: (dir: string) => Promise): Promise {
return await withTempDir("openclaw-test-", run);
@@ -56,8 +59,14 @@ describe("nodes camera helpers", () => {
writeBase64ToFile,
writeUrlToFile,
} = await import("./nodes-camera.js"));
- ({ parseScreenRecordPayload, screenRecordTempPath, writeScreenRecordToFile } =
- await import("./nodes-screen.js"));
+ ({
+ parseScreenRecordPayload,
+ parseScreenSnapshotPayload,
+ screenRecordTempPath,
+ screenSnapshotTempPath,
+ writeScreenRecordToFile,
+ writeScreenSnapshotToFile,
+ } = await import("./nodes-screen.js"));
});
beforeEach(() => {
@@ -130,6 +139,13 @@ describe("nodes camera helpers", () => {
id: "id1",
}),
).toThrow(/invalid media format/i);
+ expect(() =>
+ screenSnapshotTempPath({
+ ext: "png/../../escaped",
+ tmpDir: "/tmp",
+ id: "id1",
+ }),
+ ).toThrow(/invalid media format/i);
});
it("writes camera clip payload to temp path", async () => {
@@ -203,6 +219,10 @@ describe("nodes camera helpers", () => {
/exceeds max/i,
);
await expectPathMissing(out);
+ await expect(writeScreenSnapshotToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(
+ /exceeds max/i,
+ );
+ await expectPathMissing(out);
});
});
@@ -335,4 +355,34 @@ describe("nodes screen helpers", () => {
});
expect(p).toBe(path.join("/tmp", "openclaw-screen-record-id1.mp4"));
});
+
+ it("parses screen.snapshot payload", () => {
+ expect(
+ parseScreenSnapshotPayload({
+ format: "png",
+ base64: "Zm9v",
+ screenIndex: 1,
+ width: 1200,
+ height: 800,
+ }),
+ ).toEqual({
+ format: "png",
+ base64: "Zm9v",
+ screenIndex: 1,
+ width: 1200,
+ height: 800,
+ });
+ });
+
+ it("rejects invalid screen.snapshot payload", () => {
+ expect(() => parseScreenSnapshotPayload({ format: "png" })).toThrow(
+ /invalid screen\.snapshot payload/i,
+ );
+ });
+
+ it("builds screen snapshot temp path", () => {
+ expect(screenSnapshotTempPath({ tmpDir: "/tmp", id: "id1" })).toBe(
+ path.join("/tmp", "openclaw-screen-snapshot-id1.png"),
+ );
+ });
});
diff --git a/src/cli/nodes-screen.ts b/src/cli/nodes-screen.ts
index 70f242d85d4b..0c0a4396186f 100644
--- a/src/cli/nodes-screen.ts
+++ b/src/cli/nodes-screen.ts
@@ -45,3 +45,44 @@ export async function writeScreenRecordToFile(
) {
return writeBase64ToFile(filePath, base64, opts);
}
+
+/** Validated payload returned by `nodes screen snapshot` RPC calls. */
+export type ScreenSnapshotPayload = {
+ format: string;
+ base64: string;
+ screenIndex?: number;
+ width?: number;
+ height?: number;
+};
+
+/** Validate and normalize an unknown screen-snapshot payload. */
+export function parseScreenSnapshotPayload(value: unknown): ScreenSnapshotPayload {
+ const obj = asRecord(value);
+ const format = asString(obj.format);
+ const base64 = asString(obj.base64);
+ if (!format || !base64) {
+ throw new Error("invalid screen.snapshot payload");
+ }
+ return {
+ format,
+ base64,
+ screenIndex: typeof obj.screenIndex === "number" ? obj.screenIndex : undefined,
+ width: typeof obj.width === "number" ? obj.width : undefined,
+ height: typeof obj.height === "number" ? obj.height : undefined,
+ };
+}
+
+/** Build the temp output path for a screen snapshot artifact. */
+export function screenSnapshotTempPath(opts: { ext?: string; tmpDir?: string; id?: string }) {
+ const { tmpDir, id, ext } = resolveTempPathParts({ ...opts, ext: opts.ext ?? ".png" });
+ return path.join(tmpDir, `openclaw-screen-snapshot-${id}${ext}`);
+}
+
+/** Decode and write a screen snapshot payload to disk. */
+export async function writeScreenSnapshotToFile(
+ filePath: string,
+ base64: string,
+ opts?: { maxBytes?: number },
+) {
+ return writeBase64ToFile(filePath, base64, opts);
+}
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json
index 7c4d2c5d6011..09017097fd1c 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json
@@ -17,6 +17,7 @@
"camera_clip",
"photos_latest",
"screen_record",
+ "screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json
index 0644aa48559f..2a4e3d39058d 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json
@@ -17,6 +17,7 @@
"camera_clip",
"photos_latest",
"screen_record",
+ "screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json
index eee4d56d186d..4103926ef64d 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json
@@ -17,6 +17,7 @@
"camera_clip",
"photos_latest",
"screen_record",
+ "screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md
index 848aee5aa8ba..d1edfbc4a8c6 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md
@@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
- "chars": 45244,
- "roughTokens": 11311
+ "chars": 45275,
+ "roughTokens": 11319
},
"openClawDeveloperInstructions": {
"chars": 2988,
@@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6925
},
"totalWithDynamicToolsJson": {
- "chars": 72946,
- "roughTokens": 18237
+ "chars": 72977,
+ "roughTokens": 18245
},
"userInputText": {
"chars": 1629,
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
index 1f02a6fb8898..e3124eec7a05 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
@@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
- "chars": 44933,
- "roughTokens": 11234
+ "chars": 44964,
+ "roughTokens": 11241
},
"openClawDeveloperInstructions": {
"chars": 1964,
@@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6544
},
"totalWithDynamicToolsJson": {
- "chars": 71111,
- "roughTokens": 17778
+ "chars": 71142,
+ "roughTokens": 17786
},
"userInputText": {
"chars": 1129,
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md
index f3f317d25cb1..a8ba0140a252 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md
@@ -224,8 +224,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
- "chars": 46028,
- "roughTokens": 11507
+ "chars": 46059,
+ "roughTokens": 11515
},
"openClawDeveloperInstructions": {
"chars": 1983,
@@ -236,8 +236,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6780
},
"totalWithDynamicToolsJson": {
- "chars": 73149,
- "roughTokens": 18288
+ "chars": 73180,
+ "roughTokens": 18295
},
"userInputText": {
"chars": 1367,