refactor: consolidate core stream cleanup, watchdog locality, and approval text duplication (#99901)

* refactor(infra): share guarded body-stream cleanup across fetch consumers

* refactor(agents): extract shared runtime model locality for llm watchdogs

* refactor(auto-reply): share exec approval route resolution between command handlers

* refactor(plugin-sdk): share approval reaction hint text helpers

* refactor(plugin-sdk): share planned migration target resolution

* refactor(infra): use canonical sleep helper in backup retry

* docs(plugins): mention shared migration targets and reaction hint helpers

* chore(plugin-sdk): pin surface budgets for reaction hint and migration target helpers
This commit is contained in:
Peter Steinberger
2026-07-04 02:08:35 -07:00
committed by GitHub
parent eafe2a8d0b
commit be95bb72d4
16 changed files with 267 additions and 276 deletions
+3 -3
View File
@@ -28,7 +28,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/config-schema` | `OpenClawSchema` |
| `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` |
| `plugin-sdk/migration` | Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` |
| `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` |
| `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem`, `resolvePlannedMigrationTargets`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` |
| `plugin-sdk/health` | Doctor health-check registration, detection, repair, selection, severity, and finding types for bundled health consumers |
### Deprecated compatibility and test helpers
@@ -190,7 +190,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
| `plugin-sdk/approval-native-runtime` | Native approval target, account-binding, route-gate, forwarding fallback, and local native exec prompt suppression helpers |
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, and compatibility export for local native exec prompt suppression |
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, reaction hint text helpers, and compatibility export for local native exec prompt suppression |
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
| `plugin-sdk/approval-runtime` | Exec/plugin approval payload helpers, native approval routing/runtime helpers, and structured approval display helpers such as `formatApprovalDisplayPath` |
| `plugin-sdk/reply-dedupe` | Narrow inbound reply dedupe reset helpers |
@@ -241,7 +241,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/runtime-config-snapshot` | Current process config snapshot helpers such as `getRuntimeConfig`, `getRuntimeConfigSnapshot`, and test snapshot setters |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text barrel |
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, and compatibility export for local native exec prompt suppression |
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, reaction hint text helpers, and compatibility export for local native exec prompt suppression |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers, and structured approval display path formatting |
| `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner |
| `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize and conversation-label helpers |
+4 -25
View File
@@ -1,7 +1,9 @@
// Imessage plugin module implements approval reactions behavior.
import {
addApprovalReactionHintToText,
buildApprovalReactionHint,
createApprovalReactionTargetStore,
hasApprovalReactionHintText,
listApprovalReactionBindings,
resolveApprovalReactionTarget,
type ApprovalReactionDecisionBinding,
@@ -243,38 +245,15 @@ export function buildIMessageApprovalReactionHint(
return buildApprovalReactionHint({ allowedDecisions });
}
function insertIMessageApprovalReactionHintNearHeader(params: {
text: string;
hint: string;
}): string {
const lines = params.text.split(/\r?\n/);
const idLineIndex = lines.findIndex((line) => /^ID:\s*\S+/.test(line.trim()));
if (idLineIndex >= 0) {
const before = lines.slice(0, idLineIndex + 1).join("\n");
const after = lines
.slice(idLineIndex + 1)
.join("\n")
.replace(/^\n+/, "");
return after ? `${before}\n\n${params.hint}\n\n${after}` : `${before}\n\n${params.hint}`;
}
return `${params.hint}\n\n${params.text}`;
}
export function addIMessageApprovalReactionHintToText(params: {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): string {
if (/(^|\n)React with:\s*(\n|$)/i.test(params.text)) {
return params.text;
}
const hint = buildIMessageApprovalReactionHint(params.allowedDecisions);
return hint
? insertIMessageApprovalReactionHintNearHeader({ text: params.text, hint })
: params.text;
return addApprovalReactionHintToText(params);
}
export function appendIMessageApprovalReactionHintForOutboundMessage(text: string): string {
if (/(^|\n)React with:\s*(\n|$)/i.test(text)) {
if (hasApprovalReactionHintText(text)) {
return text;
}
const binding = extractIMessageApprovalPromptBinding(text);
+5 -31
View File
@@ -1,31 +1,5 @@
// Migrate Claude plugin module implements targets behavior.
import path from "node:path";
import {
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "openclaw/plugin-sdk/agent-runtime";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { resolveHomePath } from "./helpers.js";
export type PlannedTargets = {
workspaceDir: string;
stateDir: string;
agentDir: string;
};
export function resolveTargets(ctx: MigrationProviderContext): PlannedTargets {
const cfg = ctx.config;
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim();
const agentDir =
ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ??
(configuredAgentDir ? resolveHomePath(configuredAgentDir) : undefined) ??
path.join(ctx.stateDir, "agents", agentId, "agent");
return {
workspaceDir,
stateDir: ctx.stateDir,
agentDir,
};
}
// Migrate Claude plugin re-exports the shared migration target resolution.
export {
resolvePlannedMigrationTargets as resolveTargets,
type PlannedMigrationTargets as PlannedTargets,
} from "openclaw/plugin-sdk/migration-runtime";
+5 -31
View File
@@ -1,31 +1,5 @@
// Migrate Hermes plugin module implements targets behavior.
import path from "node:path";
import {
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "openclaw/plugin-sdk/agent-runtime";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { resolveHomePath } from "./helpers.js";
export type PlannedTargets = {
workspaceDir: string;
stateDir: string;
agentDir: string;
};
export function resolveTargets(ctx: MigrationProviderContext): PlannedTargets {
const cfg = ctx.config;
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim();
const agentDir =
ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ??
(configuredAgentDir ? resolveHomePath(configuredAgentDir) : undefined) ??
path.join(ctx.stateDir, "agents", agentId, "agent");
return {
workspaceDir,
stateDir: ctx.stateDir,
agentDir,
};
}
// Migrate Hermes plugin re-exports the shared migration target resolution.
export {
resolvePlannedMigrationTargets as resolveTargets,
type PlannedMigrationTargets as PlannedTargets,
} from "openclaw/plugin-sdk/migration-runtime";
+6 -31
View File
@@ -1,8 +1,10 @@
// Signal plugin module implements approval reactions behavior.
import { matchesApprovalRequestFilters } from "openclaw/plugin-sdk/approval-client-runtime";
import {
addApprovalReactionHintToText,
buildApprovalReactionHint,
createApprovalReactionTargetStore,
hasApprovalReactionHintText,
listApprovalReactionBindings,
resolveApprovalReactionTarget,
type ApprovalReactionDecisionBinding,
@@ -362,38 +364,11 @@ export function buildSignalApprovalReactionHint(
return buildApprovalReactionHint({ allowedDecisions });
}
function insertSignalApprovalReactionHintNearHeader(params: {
text: string;
hint: string;
}): string {
const lines = params.text.split(/\r?\n/);
const idLineIndex = lines.findIndex((line) => /^ID:\s*\S+/.test(line.trim()));
if (idLineIndex >= 0) {
const before = lines.slice(0, idLineIndex + 1).join("\n");
const after = lines
.slice(idLineIndex + 1)
.join("\n")
.replace(/^\n+/, "");
return after ? `${before}\n\n${params.hint}\n\n${after}` : `${before}\n\n${params.hint}`;
}
return `${params.hint}\n\n${params.text}`;
}
export function addSignalApprovalReactionHintToText(params: {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): string {
if (hasSignalApprovalReactionHintText(params.text)) {
return params.text;
}
const hint = buildSignalApprovalReactionHint(params.allowedDecisions);
return hint
? insertSignalApprovalReactionHintNearHeader({ text: params.text, hint })
: params.text;
}
function hasSignalApprovalReactionHintText(text?: string | null): boolean {
return /(^|\n)React with:\s*(\n|$)/i.test(text ?? "");
return addApprovalReactionHintToText(params);
}
function buildTargetRoute(params: {
@@ -571,8 +546,8 @@ function listDeliveredSignalMessageIdsWithVisibleHint(params: {
const ids = candidates
.filter((result) =>
resultsWithVisibleText.length > 0
? hasSignalApprovalReactionHintText(readSignalDeliveryVisibleText(result))
: hasSignalApprovalReactionHintText(params.payload.text),
? hasApprovalReactionHintText(readSignalDeliveryVisibleText(result))
: hasApprovalReactionHintText(params.payload.text),
)
.map((result) => normalizeOptionalString(result.messageId))
.filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown"));
@@ -595,7 +570,7 @@ export function registerSignalApprovalReactionTargetForDeliveredPayload(params:
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
return false;
}
if (!hasSignalApprovalReactionHintText(params.payload.text)) {
if (!hasApprovalReactionHintText(params.payload.text)) {
return false;
}
if (
+2 -2
View File
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 323),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10416),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5230),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10421),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5234),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3261,
@@ -198,6 +198,43 @@ function isOllamaCloudModel(model: { id?: string; provider?: string } | undefine
return bareModelId.endsWith(":cloud");
}
type RuntimeModelLocality = {
isLocalRuntimeModel: boolean;
isExplicitLocalHostnameRuntimeModel: boolean;
isSelfHostedHostnameRuntimeModel: boolean;
};
/**
* Classifies the model endpoint locality shared by the idle and first-event
* watchdogs. Ollama `*:cloud` models stay "cloud" even behind a local proxy.
*/
function resolveRuntimeModelLocality(params?: {
cfg?: OpenClawConfig;
model?: { baseUrl?: string; id?: string; provider?: string };
}): RuntimeModelLocality {
const baseUrl = params?.model?.baseUrl;
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
return {
isLocalRuntimeModel: false,
isExplicitLocalHostnameRuntimeModel: false,
isSelfHostedHostnameRuntimeModel: false,
};
}
const notCloudModel = !isOllamaCloudModel(params?.model);
return {
isLocalRuntimeModel: isLocalProviderBaseUrl(baseUrl) && notCloudModel,
isExplicitLocalHostnameRuntimeModel: isExplicitLocalHostnameBaseUrl(baseUrl) && notCloudModel,
isSelfHostedHostnameRuntimeModel:
isBareProviderHostnameBaseUrl(baseUrl) &&
(isSelfHostedProviderId(params?.model?.provider) ||
hasConfiguredLocalProviderSignal({
cfg: params?.cfg,
provider: params?.model?.provider,
})) &&
notCloudModel,
};
}
/**
* Resolves the stream-idle watchdog timeout for one embedded run. Explicit
* provider request timeouts and bounded run/agent timeouts cap the watchdog;
@@ -220,22 +257,11 @@ export function resolveLlmIdleTimeoutMs(params?: {
const hasExplicitRunTimeout =
typeof runTimeoutMs === "number" && Number.isFinite(runTimeoutMs) && runTimeoutMs > 0;
const runTimeoutIsNoTimeout = hasExplicitRunTimeout && runTimeoutMs >= MAX_TIMER_TIMEOUT_MS;
const baseUrl = params?.model?.baseUrl;
const isLocalProvider =
typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl);
const isLocalRuntimeModel = isLocalProvider && !isOllamaCloudModel(params?.model);
const isExplicitLocalHostnameRuntimeModel =
typeof baseUrl === "string" &&
baseUrl.length > 0 &&
isExplicitLocalHostnameBaseUrl(baseUrl) &&
!isOllamaCloudModel(params?.model);
const isSelfHostedHostnameRuntimeModel =
typeof baseUrl === "string" &&
baseUrl.length > 0 &&
isBareProviderHostnameBaseUrl(baseUrl) &&
(isSelfHostedProviderId(params?.model?.provider) ||
hasConfiguredLocalProviderSignal({ cfg: params?.cfg, provider: params?.model?.provider })) &&
!isOllamaCloudModel(params?.model);
const {
isLocalRuntimeModel,
isExplicitLocalHostnameRuntimeModel,
isSelfHostedHostnameRuntimeModel,
} = resolveRuntimeModelLocality(params);
const timeoutBounds = [
runTimeoutIsNoTimeout ? undefined : runTimeoutMs,
hasExplicitRunTimeout ? undefined : agentTimeoutMs,
@@ -320,22 +346,11 @@ export function resolveLlmFirstEventTimeoutMs(params?: {
const hasExplicitRunTimeout =
typeof runTimeoutMs === "number" && Number.isFinite(runTimeoutMs) && runTimeoutMs > 0;
const runTimeoutIsBounded = hasExplicitRunTimeout && runTimeoutMs < MAX_TIMER_TIMEOUT_MS;
const baseUrl = params?.model?.baseUrl;
const isLocalProvider =
typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl);
const isLocalRuntimeModel = isLocalProvider && !isOllamaCloudModel(params?.model);
const isExplicitLocalHostnameRuntimeModel =
typeof baseUrl === "string" &&
baseUrl.length > 0 &&
isExplicitLocalHostnameBaseUrl(baseUrl) &&
!isOllamaCloudModel(params?.model);
const isSelfHostedHostnameRuntimeModel =
typeof baseUrl === "string" &&
baseUrl.length > 0 &&
isBareProviderHostnameBaseUrl(baseUrl) &&
(isSelfHostedProviderId(params?.model?.provider) ||
hasConfiguredLocalProviderSignal({ cfg: params?.cfg, provider: params?.model?.provider })) &&
!isOllamaCloudModel(params?.model);
const {
isLocalRuntimeModel,
isExplicitLocalHostnameRuntimeModel,
isSelfHostedHostnameRuntimeModel,
} = resolveRuntimeModelLocality(params);
const timeoutBounds = [
runTimeoutIsBounded ? runTimeoutMs : undefined,
hasExplicitRunTimeout ? undefined : agentTimeoutMs,
+5 -45
View File
@@ -6,6 +6,7 @@
import fs from "node:fs";
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { wrapGuardedBodyStream } from "../infra/net/guarded-body-stream.js";
import {
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
type PinnedDispatcherPolicy,
@@ -25,11 +26,6 @@ const fetchWithUndiciGuard = async (
): Promise<Response> => await fetchWithUndici(input instanceof Request ? input.url : input, init);
const MCP_HTTP_MAX_REDIRECTS = 20;
const managedMcpResponseCleanupRegistry = new FinalizationRegistry<{
finalize: () => Promise<void>;
}>((held) => {
void held.finalize();
});
function resolveFetchRequest(input: RequestInfo | URL, init?: RequestInit) {
if (input instanceof Request) {
@@ -82,47 +78,11 @@ async function buildManagedMcpResponse(
return await ensureGlobalFetchResponse(response);
}
const source = response.body;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let released = false;
const cleanupRegistrationToken = {};
const finalize = async () => {
if (released) {
return;
}
released = true;
managedMcpResponseCleanupRegistry.unregister(cleanupRegistrationToken);
await reader?.cancel().catch(() => undefined);
await release().catch(() => undefined);
};
const wrappedBody = new ReadableStream<Uint8Array>({
start() {
reader = source.getReader();
},
async pull(controller) {
try {
const chunk = await reader?.read();
if (!chunk || chunk.done) {
controller.close();
await finalize();
return;
}
refreshTimeout?.();
controller.enqueue(chunk.value);
} catch (error) {
controller.error(error);
await finalize();
}
},
async cancel(reason) {
try {
await reader?.cancel(reason);
} finally {
await finalize();
}
},
const wrappedBody = wrapGuardedBodyStream({
body: response.body,
cleanup: release,
refreshTimeout,
});
managedMcpResponseCleanupRegistry.register(wrappedBody, { finalize }, cleanupRegistrationToken);
return await ensureGlobalFetchResponse(
new Response(wrappedBody, {
status: response.status,
+8 -48
View File
@@ -18,6 +18,7 @@ import {
fetchWithSsrFGuard,
withTrustedEnvProxyGuardedFetchMode,
} from "../infra/net/fetch-guard.js";
import { wrapGuardedBodyStream } from "../infra/net/guarded-body-stream.js";
import { shouldUseEnvHttpProxyForUrl } from "../infra/net/proxy-env.js";
import {
mergeSsrFPolicies,
@@ -550,12 +551,6 @@ function shouldBypassLongSdkRetry(response: Response): boolean {
return status === 429;
}
const managedStreamCleanupRegistry = new FinalizationRegistry<{ finalize: () => Promise<void> }>(
(held) => {
void held.finalize();
},
);
function buildManagedResponse(
response: Response,
release: () => Promise<void>,
@@ -569,53 +564,18 @@ function buildManagedResponse(
void release().finally(finalizeLocalServiceLease);
return response;
}
const source = response.body;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let released = false;
const cleanupRegistrationToken = {};
const finalize = async () => {
if (released) {
return;
}
released = true;
managedStreamCleanupRegistry.unregister(cleanupRegistrationToken);
try {
await reader?.cancel().catch(() => undefined);
await release().catch(() => undefined);
} finally {
finalizeLocalServiceLease();
}
};
const wrappedBody = new ReadableStream<Uint8Array>({
start() {
reader = source.getReader();
},
async pull(controller) {
const wrappedBody = wrapGuardedBodyStream({
body: response.body,
// Lease release must survive a failed guard release so local services do not leak.
cleanup: async () => {
try {
const chunk = await reader?.read();
if (!chunk || chunk.done) {
controller.close();
await finalize();
return;
}
refreshTimeout?.();
controller.enqueue(chunk.value);
} catch (error) {
controller.error(error);
await finalize();
}
},
async cancel(reason) {
try {
await reader?.cancel(reason);
await release().catch(() => undefined);
} finally {
await finalize();
finalizeLocalServiceLease();
}
},
refreshTimeout,
});
// Stream consumers should cancel deterministically; this catches abandoned
// wrapper bodies so guarded dispatchers and local-service leases do not leak.
managedStreamCleanupRegistry.register(wrappedBody, { finalize }, cleanupRegistrationToken);
return new Response(wrappedBody, {
status: response.status,
statusText: response.statusText,
+5 -11
View File
@@ -20,6 +20,7 @@ import {
deliverPrivateCommandReply,
readCommandDeliveryTarget,
readCommandMessageThreadId,
resolveCommandExecApprovalRoute,
resolvePrivateCommandApprovalRouteExpiresAtMs,
resolvePrivateCommandRouteTargets,
type PrivateCommandRouteTarget,
@@ -297,7 +298,6 @@ async function requestGatewayDiagnosticsExportApproval(
sessionKey: params.sessionKey,
config: params.cfg,
});
const messageThreadId = readCommandMessageThreadId(params);
const command = buildGatewayDiagnosticsExportJsonCommand();
try {
const execTool = deps.createExecTool({
@@ -316,16 +316,10 @@ async function requestGatewayDiagnosticsExportApproval(
sessionKey: params.sessionKey,
mainKey: params.cfg.session?.mainKey,
sessionScope: params.cfg.session?.scope,
messageProvider: options.privateApprovalTarget?.channel ?? params.command.channel,
currentChannelId: options.privateApprovalTarget?.to ?? readCommandDeliveryTarget(params),
currentThreadTs: options.privateApprovalTarget
? options.privateApprovalTarget.threadId == null
? undefined
: String(options.privateApprovalTarget.threadId)
: messageThreadId,
accountId: options.privateApprovalTarget
? (options.privateApprovalTarget.accountId ?? undefined)
: (params.ctx.AccountId ?? undefined),
...resolveCommandExecApprovalRoute({
commandParams: params,
privateApprovalTarget: options.privateApprovalTarget,
}),
notifyOnExit: params.cfg.tools?.exec?.notifyOnExit,
notifyOnExitEmptySuccess: params.cfg.tools?.exec?.notifyOnExitEmptySuccess,
});
@@ -15,6 +15,7 @@ import {
deliverPrivateCommandReply,
readCommandDeliveryTarget,
readCommandMessageThreadId,
resolveCommandExecApprovalRoute,
resolvePrivateCommandApprovalRouteExpiresAtMs,
resolvePrivateCommandRouteTargets,
type PrivateCommandRouteTarget,
@@ -172,7 +173,6 @@ async function requestTrajectoryExportApproval(
sessionKey: params.sessionKey,
config: params.cfg,
});
const messageThreadId = readCommandMessageThreadId(params);
try {
const execTool = deps.createExecTool({
host: "gateway",
@@ -190,16 +190,10 @@ async function requestTrajectoryExportApproval(
sessionStore: params.cfg.session?.store,
mainKey: params.cfg.session?.mainKey,
sessionScope: params.cfg.session?.scope,
messageProvider: options.privateApprovalTarget?.channel ?? params.command.channel,
currentChannelId: options.privateApprovalTarget?.to ?? readCommandDeliveryTarget(params),
currentThreadTs: options.privateApprovalTarget
? options.privateApprovalTarget.threadId == null
? undefined
: String(options.privateApprovalTarget.threadId)
: messageThreadId,
accountId: options.privateApprovalTarget
? (options.privateApprovalTarget.accountId ?? undefined)
: (params.ctx.AccountId ?? undefined),
...resolveCommandExecApprovalRoute({
commandParams: params,
privateApprovalTarget: options.privateApprovalTarget,
}),
notifyOnExit: params.cfg.tools?.exec?.notifyOnExit,
notifyOnExitEmptySuccess: params.cfg.tools?.exec?.notifyOnExitEmptySuccess,
});
@@ -127,6 +127,36 @@ export function readCommandDeliveryTarget(params: HandleCommandsParams): string
);
}
/**
* Resolves where an exec approval prompt for a command should be delivered:
* the private owner-DM target when one was resolved, else the originating
* command surface. Keeps the fallback ternaries in one place so private and
* origin routing cannot drift between command handlers.
*/
export function resolveCommandExecApprovalRoute(params: {
commandParams: HandleCommandsParams;
privateApprovalTarget?: PrivateCommandRouteTarget;
}): {
messageProvider: string;
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
accountId: string | undefined;
} {
const target = params.privateApprovalTarget;
return {
messageProvider: target?.channel ?? params.commandParams.command.channel,
currentChannelId: target?.to ?? readCommandDeliveryTarget(params.commandParams),
currentThreadTs: target
? target.threadId == null
? undefined
: String(target.threadId)
: readCommandMessageThreadId(params.commandParams),
accountId: target
? (target.accountId ?? undefined)
: (params.commandParams.ctx.AccountId ?? undefined),
};
}
function listPrivateCommandRouteCandidateChannels(originChannel: string) {
const plugins = [getLoadedChannelPlugin(originChannel), ...listChannelPlugins()].filter(
(plugin): plugin is NonNullable<ReturnType<typeof getLoadedChannelPlugin>> =>
+1 -6
View File
@@ -18,6 +18,7 @@ import { isPathWithin } from "../commands/cleanup-utils.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { resolveHomeDir, resolveUserPath } from "../utils.js";
import { sleep } from "../utils/sleep.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { isVolatileBackupPath } from "./backup-volatile-filter.js";
import { writeJson } from "./json-files.js";
@@ -167,12 +168,6 @@ function isTarEofRaceError(err: unknown): boolean {
return /(did not encounter expected|encountered unexpected) EOF|TAR_BAD_ARCHIVE/i.test(message);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export type BackupTarRetryLogger = (message: string) => void;
async function writeTarArchiveWithRetry(params: {
+70
View File
@@ -0,0 +1,70 @@
/**
* Shared body-stream cleanup for guarded fetch consumers (`fetchWithSsrFGuard`
* callers that re-wrap streaming responses).
*/
// Catches wrapper bodies abandoned without cancel/consume so guarded dispatchers
// (and caller resources hooked into `cleanup`) do not leak with the stream.
const guardedBodyCleanupRegistry = new FinalizationRegistry<{ finalize: () => Promise<void> }>(
(held) => {
void held.finalize();
},
);
/**
* Wraps a guarded response body so caller cleanup runs exactly once when the
* stream completes, errors, is cancelled, or is garbage-collected unconsumed.
* Cleanup failures are swallowed: releasing guard resources must never break
* response consumption.
*/
export function wrapGuardedBodyStream(params: {
body: ReadableStream<Uint8Array>;
cleanup: () => Promise<void> | void;
refreshTimeout?: () => void;
}): ReadableStream<Uint8Array> {
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let finalized = false;
const cleanupRegistrationToken = {};
const finalize = async () => {
if (finalized) {
return;
}
finalized = true;
guardedBodyCleanupRegistry.unregister(cleanupRegistrationToken);
await reader?.cancel().catch(() => undefined);
try {
await params.cleanup();
} catch {
// Best effort: guard cleanup must not surface into stream consumers.
}
};
const wrappedBody = new ReadableStream<Uint8Array>({
start() {
reader = params.body.getReader();
},
async pull(controller) {
try {
const chunk = await reader?.read();
if (!chunk || chunk.done) {
controller.close();
await finalize();
return;
}
params.refreshTimeout?.();
controller.enqueue(chunk.value);
} catch (error) {
controller.error(error);
await finalize();
}
},
async cancel(reason) {
try {
await reader?.cancel(reason);
} finally {
await finalize();
}
},
});
guardedBodyCleanupRegistry.register(wrappedBody, { finalize }, cleanupRegistrationToken);
return wrappedBody;
}
@@ -130,6 +130,43 @@ export function buildApprovalReactionHint(params: {
return `React with:\n\n${bindings.map((binding) => `${binding.emoji} ${binding.label}`).join("\n")}`;
}
const APPROVAL_REACTION_HINT_PRESENT_RE = /(^|\n)React with:\s*(\n|$)/i;
/** True when approval prompt text already carries a reaction hint block. */
export function hasApprovalReactionHintText(text?: string | null): boolean {
return APPROVAL_REACTION_HINT_PRESENT_RE.test(text ?? "");
}
/** Inserts a reaction hint after the `ID: <id>` header line, else prepends it. */
export function insertApprovalReactionHintNearIdHeader(params: {
text: string;
hint: string;
}): string {
const lines = params.text.split(/\r?\n/);
const idLineIndex = lines.findIndex((line) => /^ID:\s*\S+/.test(line.trim()));
if (idLineIndex >= 0) {
const before = lines.slice(0, idLineIndex + 1).join("\n");
const after = lines
.slice(idLineIndex + 1)
.join("\n")
.replace(/^\n+/, "");
return after ? `${before}\n\n${params.hint}\n\n${after}` : `${before}\n\n${params.hint}`;
}
return `${params.hint}\n\n${params.text}`;
}
/** Adds the canonical reaction hint to approval prompt text unless one is present. */
export function addApprovalReactionHintToText(params: {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): string {
if (hasApprovalReactionHintText(params.text)) {
return params.text;
}
const hint = buildApprovalReactionHint({ allowedDecisions: params.allowedDecisions });
return hint ? insertApprovalReactionHintNearIdHeader({ text: params.text, hint }) : params.text;
}
/** Normalize reaction emoji so skin-tone and text/presentation variants match canonical bindings. */
export function normalizeApprovalReactionEmoji(reactionKey: string): string {
const normalized = reactionKey
+34
View File
@@ -3,7 +3,10 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentConfig } from "../agents/agent-scope-config.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { pathExists } from "../infra/fs-safe.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import type {
MigrationApplyResult,
MigrationItem,
@@ -19,6 +22,37 @@ import {
export type { MigrationApplyResult, MigrationItem } from "../plugins/types.js";
/** Directories a migration provider writes imported agent data into. */
export type PlannedMigrationTargets = {
workspaceDir: string;
stateDir: string;
agentDir: string;
};
/**
* Resolves the default agent's workspace/state/agent directories for a
* migration run. Prefers the runtime resolver, then a configured agentDir
* (home-relative paths honor the effective-home resolution used by the rest
* of the product), then the canonical state-dir layout.
*/
export function resolvePlannedMigrationTargets(
ctx: MigrationProviderContext,
): PlannedMigrationTargets {
const cfg = ctx.config;
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim();
const agentDir =
ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ??
(configuredAgentDir ? resolveHomeRelativePath(configuredAgentDir) : undefined) ??
path.join(ctx.stateDir, "agents", agentId, "agent");
return {
workspaceDir,
stateDir: ctx.stateDir,
agentDir,
};
}
/** Wrap migration runtime config access with a cached mutable snapshot during apply. */
export function withCachedMigrationConfigRuntime(
runtime: MigrationProviderContext["runtime"] | undefined,