fix: preserve Codex-bound conversation history across session rotation (#112016)

* fix: preserve Codex conversation bindings

* test: harden Codex live gateway stress

* chore: refresh plugin SDK API baseline

* test: tighten restart recovery fixtures

* test: complete restart recovery recorder fixture

* fix: keep plugin command authorization fail closed

* fix: restore private queue source imports

* fix: keep private SDK imports inside core

* fix: keep speech core on private SDK paths

* fix: stage private SDK exports for live tests

* fix: preserve plugin-bound gateway history

* fix: block transcript writes after binding races

* test: complete live stage helper contracts

* test: align binding mocks with current types

* fix: preserve compiler OOM diagnostics

* chore: move release notes to PR context
This commit is contained in:
Peter Steinberger
2026-07-20 19:42:56 -07:00
committed by GitHub
parent 3abf8ce964
commit 2bb6b870aa
66 changed files with 1516 additions and 170 deletions
+4
View File
@@ -120,6 +120,10 @@ ENV GIT_COMMIT=${GIT_COMMIT} \
COPY . .
# The build stage also backs non-root live-test containers. Build contexts preserve
# host modes, so normalize readability before Node resolves workspace packages.
RUN chmod -R a+rX /app
# Normalize extension paths now so runtime COPY preserves safe modes
# without adding a second full extensions layer.
RUN for dir in /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} /app/.agent /app/.agents; do \
@@ -94,7 +94,7 @@ c0ffaed532578cf33493992e1ff806b2268b8e3774a92edbaede5cf5bda162a6 module/media-u
8d8c0c4ebfc6e0c6125df3ae64ec66c10cd797a325de5ea7869fcd84068ab290 module/persistent-dedupe
987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime
a5e4304b8b5878d5b7b7732939e1c91d009b4d3e9ee1c41d2f6ccde81c10eb99 module/plugin-entry
b41a69c03c3a671a120963bebec65f379261220b72a4636242223d8bb15b830c module/plugin-runtime
8097910898d08166f3e2e3676de5dac43f3442cfb1faa1b894ded353d973d09d module/plugin-runtime
83df49f1fcb2fc4ac1bd2406a87a10be71bbcdb0967f61e9a6ccaa23c534149f module/provider-auth
71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime
56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture
+1 -1
View File
@@ -344,7 +344,7 @@ Docker notes:
the Gateway and physical Codex app-server, then repeats the output and
compaction wave. Tune the bounded work with
`OPENCLAW_LIVE_CODEX_HARNESS_COMPACTION_STRESS_TURNS` (1-8) and
`OPENCLAW_LIVE_CODEX_HARNESS_LARGE_OUTPUT_BYTES` (100000-1000000).
`OPENCLAW_LIVE_CODEX_HARNESS_LARGE_OUTPUT_BYTES` (100000-800000).
- Optional loop-relay opt-out probe:
`OPENCLAW_LIVE_CODEX_HARNESS_DISABLE_LOOP_RELAY=1`
- The requested thinking preference may map to the nearest effort advertised
+60 -10
View File
@@ -13,7 +13,13 @@ import {
} from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const scanRoots = resolveSourceRoots(repoRoot, ["src", "extensions", "scripts", "test"]);
const scanRoots = resolveSourceRoots(repoRoot, [
"src",
"packages",
"extensions",
"scripts",
"test",
]);
function readPackageExports() {
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
@@ -49,6 +55,28 @@ function parsePluginSdkSubpath(specifier) {
return subpath || null;
}
function isGeneratedBuildArtifact(filePath) {
return normalizeRepoPath(repoRoot, filePath).split("/").includes("dist");
}
function isRuntimeModuleReference(node) {
// With verbatimModuleSyntax, inline `type` specifiers emit an empty import/export and still
// resolve the module. Only declaration-level `import type` and `export type` are erased.
if (ts.isImportDeclaration(node)) {
return !node.importClause?.isTypeOnly;
}
if (ts.isExportDeclaration(node)) {
return !node.isTypeOnly;
}
if (ts.isImportTypeNode(node)) {
return false;
}
if (ts.isImportEqualsDeclaration(node)) {
return !node.isTypeOnly;
}
return true;
}
function compareEntries(left, right) {
return (
left.file.localeCompare(right.file) ||
@@ -63,22 +91,44 @@ async function collectViolations() {
const entrypoints = readEntrypoints();
const exports = readPackageExports();
const privateLocalOnlySubpaths = readPrivateLocalOnlySubpaths();
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true })).toSorted(
(left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
// Workspace packages resolve private facades through root TS paths and bundle them into dist;
// live jiti source stages inject the same private map. Core src callers must stay relative.
const coreRuntimeFiles = new Set(
(
await collectTypeScriptFilesFromRoots(resolveSourceRoots(repoRoot, ["src"]), {
includeTests: false,
extraTestSuffixes: [".test-support.ts", ".test-loader.ts", ".test-fixtures.ts"],
})
).filter((filePath) => !isGeneratedBuildArtifact(filePath)),
);
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true }))
.filter((filePath) => !isGeneratedBuildArtifact(filePath))
.toSorted((left, right) =>
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
);
const violations = [];
for (const filePath of files) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
function push(kind, specifierNode, specifier) {
function push(kind, node, specifierNode, specifier) {
const subpath = parsePluginSdkSubpath(specifier);
if (!subpath) {
return;
}
if (privateLocalOnlySubpaths.has(subpath)) {
const repoPath = normalizeRepoPath(repoRoot, filePath);
if (coreRuntimeFiles.has(filePath) && isRuntimeModuleReference(node)) {
violations.push({
file: repoPath,
line: toLine(sourceFile, specifierNode),
kind,
specifier,
subpath,
reason: "private runtime helper used by core must use a relative import",
});
}
return;
}
@@ -99,12 +149,12 @@ async function collectViolations() {
kind,
specifier,
subpath,
missingFrom,
reason: `missing from ${missingFrom.join(" and ")}`,
});
}
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
push(kind, specifierNode, specifier);
visitModuleSpecifiers(ts, sourceFile, ({ kind, node, specifier, specifierNode }) => {
push(kind, node, specifierNode, specifier);
});
}
@@ -119,11 +169,11 @@ async function main() {
}
console.error(
"Rule: every referenced openclaw/plugin-sdk/<subpath> must exist in the public package exports.",
"Rule: every referenced openclaw/plugin-sdk/<subpath> must be public or use its required private boundary.",
);
for (const violation of violations) {
console.error(
`- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier} missing from ${violation.missingFrom.join(" and ")}`,
`- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier}: ${violation.reason}`,
);
}
process.exit(1);
+2 -2
View File
@@ -51,13 +51,13 @@ docker_build_args_need_buildx() {
docker_build_transient_failure() {
local log_file="$1"
grep -Eqi \
'frontend grpc server closed unexpectedly|failed to dial gRPC|no active session|buildkit.*connection.*closed|rpc error: code = Unavailable|failed to fetch oauth token:.*(5[0-9][0-9]|Gateway Timeout)|unexpected status from .*: 5[0-9][0-9]|TLS handshake timeout|net/http: TLS handshake timeout|i/o timeout|connection reset by peer' \
'frontend grpc server closed unexpectedly|failed to dial gRPC|no active session|buildkit.*connection.*closed|rpc error: code = Unavailable|failed to fetch oauth token:.*(5[0-9][0-9]|Gateway Timeout)|unexpected status from .*: 5[0-9][0-9]|TLS handshake timeout|net/http: TLS handshake timeout|ConnectTimeoutError|Connect Timeout Error|i/o timeout|connection reset by peer' \
"$log_file"
}
docker_build_resource_exhausted_failure() {
local log_file="$1"
grep -Eqi 'ResourceExhausted|cannot allocate memory|out of memory|exit code: 137|signal: killed|Killed' "$log_file"
grep -Eqi 'ResourceExhausted|cannot allocate memory|out of memory|exit code: 137|signal: killed|fatal error: killed signal terminated program|(^|#[0-9]+ [0-9.]+ )Killed[[:space:]]*$' "$log_file"
}
docker_build_print_resource_exhausted_hint() {
+3
View File
@@ -37,6 +37,9 @@ openclaw_live_stage_source_tree() {
if [ "$status" -gt 1 ]; then
return "$status"
fi
local scripts_dir="${OPENCLAW_LIVE_DOCKER_SCRIPTS_DIR:-/src/scripts}"
node "$scripts_dir/live-docker-stage-private-sdk-exports.mjs" "$dest_dir"
}
openclaw_live_link_runtime_tree() {
@@ -0,0 +1 @@
export function addStagedPrivatePluginSdkExports(repoRoot: string): void;
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const PRIVATE_SUBPATH_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
export function addStagedPrivatePluginSdkExports(repoRoot) {
const packagePath = path.join(repoRoot, "package.json");
const privateSubpathsPath = path.join(
repoRoot,
"scripts",
"lib",
"plugin-sdk-private-local-only-subpaths.json",
);
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
const privateSubpaths = JSON.parse(fs.readFileSync(privateSubpathsPath, "utf8"));
if (!packageJson.exports || typeof packageJson.exports !== "object") {
throw new Error("staged package.json must define object exports");
}
if (!Array.isArray(privateSubpaths)) {
throw new Error("private plugin SDK subpaths must be an array");
}
for (const subpath of privateSubpaths) {
if (typeof subpath !== "string" || !PRIVATE_SUBPATH_PATTERN.test(subpath)) {
throw new Error(`invalid private plugin SDK subpath: ${String(subpath)}`);
}
const sourcePath = `./src/plugin-sdk/${subpath}.ts`;
if (!fs.existsSync(path.join(repoRoot, sourcePath))) {
throw new Error(`missing private plugin SDK source: ${sourcePath}`);
}
packageJson.exports[`./plugin-sdk/${subpath}`] ??= {
types: sourcePath,
default: sourcePath,
};
}
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : undefined;
if (invokedPath === fileURLToPath(import.meta.url)) {
addStagedPrivatePluginSdkExports(path.resolve(process.argv[2] ?? "."));
}
@@ -439,6 +439,8 @@ DOCKER_RUN_ARGS+=(--rm -t \
-e OPENCLAW_LIVE_CODEX_BIND="${OPENCLAW_LIVE_CODEX_BIND:-}" \
-e OPENCLAW_LIVE_CODEX_BIND_MODEL="${OPENCLAW_LIVE_CODEX_BIND_MODEL:-}" \
-e OPENCLAW_LIVE_CODEX_BIND_PROVIDER="${OPENCLAW_LIVE_CODEX_BIND_PROVIDER:-}" \
-e OPENCLAW_LIVE_CODEX_BIND_REQUEST_TIMEOUT_MS="${OPENCLAW_LIVE_CODEX_BIND_REQUEST_TIMEOUT_MS:-}" \
-e OPENCLAW_LIVE_CODEX_BIND_TIMEOUT_MS="${OPENCLAW_LIVE_CODEX_BIND_TIMEOUT_MS:-}" \
-e OPENCLAW_LIVE_CODEX_TEST_FILES="${OPENCLAW_LIVE_CODEX_TEST_FILES:-}" \
-e OPENCLAW_LIVE_TEST=1 \
-e OPENCLAW_VITEST_FS_MODULE_CACHE=0)
+1
View File
@@ -1294,6 +1294,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/lib/format-generated-module.mjs", ["test/scripts/format-generated-module.test.ts"]],
["scripts/lib/ios-version.ts", ["test/scripts/ios-version.test.ts"]],
["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]],
["scripts/live-docker-stage-private-sdk-exports.mjs", ["test/scripts/live-docker-stage.test.ts"]],
[
"scripts/lib/local-heavy-check-runtime.d.mts",
["test/scripts/local-heavy-check-runtime.test.ts"],
+1 -1
View File
@@ -1,5 +1,5 @@
/** Per-session async queue wrapper used by ACP manager operations. */
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
/** Per-session async queue that serializes ACP runtime operations and exposes queue depth. */
export class SessionActorQueue {
+1 -1
View File
@@ -1,4 +1,3 @@
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
/**
* OAuth credential manager.
* Resolves usable access tokens, refreshes expired credentials under global
@@ -9,6 +8,7 @@ import { normalizeSecretInputString } from "../../config/types.secrets.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { withFileLock } from "../../infra/file-lock.js";
import { redactSensitiveText } from "../../logging/redact.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { asDateTimestampMs } from "../../shared/number-coercion.js";
import { OAUTH_REFRESH_CALL_TIMEOUT_MS, OAUTH_REFRESH_LOCK_OPTIONS, log } from "./constants.js";
import { shouldMirrorRefreshedOAuthCredential } from "./oauth-identity.js";
+1 -1
View File
@@ -13,7 +13,6 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
@@ -26,6 +25,7 @@ import { tempWorkspace } from "../../infra/private-temp-workspace.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import type { ImageContent } from "../../llm/types.js";
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { listRegisteredPluginAgentPromptGuidance } from "../../plugins/command-registry-state.js";
import type { BootstrapMode } from "../bootstrap-mode.js";
import type { EmbeddedContextFile } from "../embedded-agent-helpers.js";
@@ -1,4 +1,4 @@
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
import { MODELS_JSON_STATE } from "./models-config-state.js";
export function resetModelsJsonReadyCacheForTest(): void {
+1 -1
View File
@@ -1,6 +1,6 @@
// Process-wide models.json coordination state. Dynamic imports can load this
// module multiple times, so Symbol.for keeps write locks and ready-cache shared.
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
const MODELS_JSON_STATE_KEY = Symbol.for("openclaw.modelsJsonState");
+1 -1
View File
@@ -9,7 +9,6 @@ import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { createJiti } from "jiti/static";
import * as bundledLlm from "openclaw/plugin-sdk/llm";
// Static imports of packages that extensions may use.
// These MUST be static so Bun bundles them into the compiled binary.
// The virtualModules option then makes them available to extensions.
@@ -17,6 +16,7 @@ import * as bundledTypebox from "typebox";
import * as bundledTypeboxCompile from "typebox/compile";
import * as bundledTypeboxFormat from "typebox/format";
import * as bundledTypeboxValue from "typebox/value";
import * as bundledLlm from "../../../plugin-sdk/llm.js";
import { installOpenClawInternalCorePackageNativeResolver } from "../../../plugins/plugin-sdk-native-resolver.js";
import {
buildPluginLoaderAliasMap,
@@ -5,7 +5,7 @@
*/
import { realpathSync } from "node:fs";
import { resolve } from "node:path";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { KeyedAsyncQueue } from "../../../plugin-sdk/keyed-async-queue.js";
const fileMutationQueue = new KeyedAsyncQueue();
+3
View File
@@ -245,6 +245,9 @@ export type ReplyPayloadMetadata = {
sourceReplyTranscriptMirror?: {
sessionKey: string;
agentId?: string;
expectedSessionId?: string;
/** Delivery stays live, but neither side may be appended to a transcript. */
transcriptWriteBlocked?: boolean;
text?: string;
mediaUrls?: string[];
idempotencyKey?: string;
+15
View File
@@ -1721,6 +1721,21 @@ export async function runReplyAgent(params: {
requesterAccountId:
followupRun.originatingAccountId ?? sessionCtx.AccountId ?? followupRun.run.agentAccountId,
requesterSenderId: sessionCtx.SenderId,
resolveUserTurnTarget: ({
entry,
sessionId,
sessionKey: targetSessionKey,
storePath: targetStorePath,
}) => ({
sessionId,
sessionKey: targetSessionKey,
sessionEntry: entry,
...(activeSessionStore ? { sessionStore: activeSessionStore } : {}),
storePath: targetStorePath,
agentId: followupRun.run.agentId,
cwd: followupRun.run.workspaceDir,
config: cfg,
}),
...(sessionKey ? { sessionKey } : {}),
setEntry: (entry) => {
activeSessionEntry = entry;
@@ -446,6 +446,7 @@ async function executeCodexDiagnosticsAddon(
config: params.cfg,
from: params.command.from,
to: params.command.to,
originatingTo: normalizeOptionalString(params.ctx.OriginatingTo),
accountId: params.ctx.AccountId ?? undefined,
messageThreadId:
typeof params.ctx.MessageThreadId === "string" ||
+1
View File
@@ -50,6 +50,7 @@ export const handlePluginCommand: CommandHandler = async (
config: cfg,
from: command.from,
to: command.to,
originatingTo: normalizeOptionalString(params.ctx.OriginatingTo),
accountId: params.ctx.AccountId ?? undefined,
messageThreadId:
typeof params.ctx.MessageThreadId === "string" ||
@@ -16,6 +16,7 @@ import {
emptyConfig,
hookMocks,
messageAuditMocks,
mocks,
sessionBindingMocks,
sessionStoreMocks,
ttsMocks,
@@ -163,9 +164,226 @@ describe("dispatchReplyFromConfig", () => {
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "Codex native reply" });
expect(
getReplyPayloadMetadata(
firstMockArg(
dispatcher.sendFinalReply as ReturnType<typeof vi.fn>,
"plugin reply",
) as ReplyPayload,
)?.sourceReplyTranscriptMirror,
).toBeUndefined();
expect(replyResolver).not.toHaveBeenCalled();
});
it("persists Gateway plugin-bound turns and routed replies in the binding session", async () => {
setNoAbort();
hookMocks.runner.hasHooks.mockImplementation(
((hookName?: string) => hookName === "inbound_claim") as () => boolean,
);
hookMocks.registry.plugins = [{ id: "codex", status: "loaded" }];
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
status: "handled",
result: { handled: true, reply: { text: "Codex bound reply" } },
});
const targetSessionKey = "plugin-binding:codex:history123";
const targetSessionEntry = {
sessionId: "bound-session-id",
updatedAt: Date.now(),
};
sessionStoreMocks.currentEntry = {
sessionId: "source-session-id",
updatedAt: Date.now(),
};
sessionStoreMocks.entriesBySessionKey.set(targetSessionKey, targetSessionEntry);
sessionStoreMocks.loadSessionStoreEntry.mockImplementation((...args: unknown[]) => {
const params = args[0] as { sessionKey: string };
return (
sessionStoreMocks.entriesBySessionKey.get(params.sessionKey) ??
sessionStoreMocks.currentEntry
);
});
sessionBindingMocks.resolveByConversation.mockReturnValue({
bindingId: "binding-history-1",
targetSessionKey,
targetKind: "session",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "user:U123",
},
status: "active",
boundAt: 1710000000000,
metadata: {
pluginBindingOwner: "plugin",
pluginId: "codex",
pluginRoot: "/plugins/codex",
},
} satisfies SessionBindingRecord);
const persistApproved = vi.fn(async () => ({
appended: true,
sessionFile: "sqlite:bound-session-id",
sessionEntry: targetSessionEntry,
messageId: "user-turn-1",
message: { role: "user" as const, content: "continue", timestamp: Date.now() },
}));
const markBlocked = vi.fn();
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
Provider: "openclaw",
Surface: "openclaw",
OriginatingChannel: "slack",
OriginatingTo: "user:U123",
To: "user:U123",
AccountId: "default",
CommandAuthorized: true,
BodyForAgent: "continue",
RawBody: "continue",
Body: "continue",
MessageSid: "msg-plugin-history",
SessionKey: "agent:main:main",
}),
cfg: emptyConfig,
dispatcher,
replyOptions: {
userTurnTranscriptRecorder: {
hasPersisted: () => false,
markBlocked,
persistApproved,
} as never,
},
replyResolver,
});
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
expect(persistApproved).toHaveBeenCalledWith({
target: expect.objectContaining({
sessionId: "bound-session-id",
sessionKey: targetSessionKey,
sessionEntry: targetSessionEntry,
}),
expectedSessionId: "bound-session-id",
retryIfUnpersisted: true,
});
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
payload: { text: "Codex bound reply" },
sessionKey: targetSessionKey,
policySessionKey: targetSessionKey,
}),
);
const routedCall = firstMockArg(mocks.routeReply, "plugin binding route") as {
payload: ReplyPayload;
};
expect(getReplyPayloadMetadata(routedCall.payload)?.sourceReplyTranscriptMirror).toMatchObject({
agentId: "main",
expectedSessionId: "bound-session-id",
sessionKey: targetSessionKey,
});
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(replyResolver).not.toHaveBeenCalled();
const rotatedTargetSessionEntry = {
sessionId: "rotated-bound-session-id",
updatedAt: Date.now(),
};
persistApproved.mockImplementationOnce(async () => {
sessionStoreMocks.entriesBySessionKey.set(targetSessionKey, rotatedTargetSessionEntry);
return undefined as never;
});
mocks.routeReply.mockClear();
const rotatedDispatcher = createDispatcher();
const rotatedResult = await dispatchReplyFromConfig({
ctx: buildTestCtx({
Provider: "openclaw",
Surface: "openclaw",
OriginatingChannel: "slack",
OriginatingTo: "user:U123",
To: "user:U123",
AccountId: "default",
CommandAuthorized: true,
BodyForAgent: "continue after reset",
RawBody: "continue after reset",
Body: "continue after reset",
MessageSid: "msg-plugin-history-rotated",
SessionKey: "agent:main:main",
}),
cfg: emptyConfig,
dispatcher: rotatedDispatcher,
replyOptions: {
userTurnTranscriptRecorder: {
hasPersisted: () => false,
markBlocked,
persistApproved,
} as never,
},
replyResolver,
});
expect(rotatedResult).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
const rotatedRoutedCall = firstMockArg(mocks.routeReply, "rotated plugin binding route") as {
payload: ReplyPayload;
sessionKey: string;
};
expect(rotatedRoutedCall.sessionKey).toBe(targetSessionKey);
expect(rotatedRoutedCall.payload).toEqual({ text: "Codex bound reply" });
expect(
getReplyPayloadMetadata(rotatedRoutedCall.payload)?.sourceReplyTranscriptMirror,
).toMatchObject({
expectedSessionId: "rotated-bound-session-id",
sessionKey: targetSessionKey,
});
expect(markBlocked).not.toHaveBeenCalled();
expect(rotatedDispatcher.sendFinalReply).not.toHaveBeenCalled();
persistApproved.mockResolvedValueOnce(undefined as never);
mocks.routeReply.mockClear();
const blockedDispatcher = createDispatcher();
await dispatchReplyFromConfig({
ctx: buildTestCtx({
Provider: "openclaw",
Surface: "openclaw",
OriginatingChannel: "slack",
OriginatingTo: "user:U123",
To: "user:U123",
AccountId: "default",
CommandAuthorized: true,
BodyForAgent: "continue during second reset",
RawBody: "continue during second reset",
Body: "continue during second reset",
MessageSid: "msg-plugin-history-blocked",
SessionKey: "agent:main:main",
}),
cfg: emptyConfig,
dispatcher: blockedDispatcher,
replyOptions: {
userTurnTranscriptRecorder: {
hasPersisted: () => false,
markBlocked,
persistApproved,
} as never,
},
replyResolver,
});
const blockedRoutedCall = firstMockArg(mocks.routeReply, "blocked plugin binding route") as {
payload: ReplyPayload;
sessionKey: string;
};
expect(blockedRoutedCall.sessionKey).toBe(targetSessionKey);
expect(
getReplyPayloadMetadata(blockedRoutedCall.payload)?.sourceReplyTranscriptMirror,
).toMatchObject({
expectedSessionId: "rotated-bound-session-id",
sessionKey: targetSessionKey,
transcriptWriteBlocked: true,
});
expect(markBlocked).toHaveBeenCalledTimes(1);
expect(blockedDispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("routes plugin-owned Discord DM bindings to the owning plugin before generic inbound claim broadcast", async () => {
setNoAbort();
hookMocks.runner.hasHooks.mockImplementation(
@@ -17,6 +17,7 @@ import {
} from "../../test-utils/channel-plugins.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { shouldBypassPluginOwnedBindingForCommand } from "./dispatch-from-config.plugin-binding.js";
import {
createDispatcher,
diagnosticMocks,
@@ -819,6 +820,69 @@ describe("dispatchReplyFromConfig", () => {
expect(replyResolver).not.toHaveBeenCalled();
});
it("looks up plugin bindings with the canonical conversation target", async () => {
setNoAbort();
hookMocks.runner.hasHooks.mockImplementation(
((hookName?: string) => hookName === "inbound_claim") as () => boolean,
);
hookMocks.registry.plugins = [{ id: "codex", status: "loaded" }];
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
status: "handled",
result: { handled: true },
});
const binding = {
bindingId: "binding-slack-user",
targetSessionKey: "plugin-binding:codex:slack-user",
targetKind: "session",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "user:U123",
},
status: "active",
boundAt: 1710000000000,
metadata: {
pluginBindingOwner: "plugin",
pluginId: "codex",
pluginRoot: "/tmp/codex",
},
} satisfies SessionBindingRecord;
sessionBindingMocks.resolveByConversation.mockImplementation((conversation) =>
conversation.conversationId === "user:U123" ? binding : null,
);
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "openclaw",
Surface: "openclaw",
OriginatingChannel: "slack",
OriginatingTo: "user:U123",
From: "user:U123",
To: "user:U123",
AccountId: "default",
Body: "hello",
SessionKey: "main",
});
const replyResolver = vi.fn(async () => ({ text: "must not run" }) satisfies ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({
channel: "slack",
accountId: "default",
conversationId: "user:U123",
parentConversationId: undefined,
});
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
"codex",
expect.any(Object),
expect.objectContaining({
conversationId: "U123",
pluginBinding: expect.objectContaining({ bindingId: "binding-slack-user" }),
}),
);
expect(replyResolver).not.toHaveBeenCalled();
});
it("holds session lifecycle mutation until an interrupted plugin claim exits", async () => {
setNoAbort();
hookMocks.runner.hasHooks.mockImplementation(
@@ -1716,7 +1780,7 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("lets authorized plugin-owned binding commands fall through to command processing", async () => {
it("lets authorized gateway-style plugin commands escape plugin-owned bindings", async () => {
setNoAbort();
expect(
registerPluginCommand(
@@ -1775,10 +1839,10 @@ describe("dispatchReplyFromConfig", () => {
AccountId: "default",
SenderId: "user-9",
SenderUsername: "ada",
CommandSource: "text",
CommandAuthorized: true,
WasMentioned: false,
CommandBody: "/codex detach",
BodyForCommands: "/codex detach",
RawBody: "/codex detach",
Body: "/codex detach",
MessageSid: "msg-claim-plugin-command-escape",
@@ -1786,6 +1850,15 @@ describe("dispatchReplyFromConfig", () => {
});
const replyResolver = vi.fn(async () => ({ text: "detached" }) satisfies ReplyPayload);
expect(
shouldBypassPluginOwnedBindingForCommand(
{ ...ctx, CommandAuthorized: "false" } as unknown as Parameters<
typeof shouldBypassPluginOwnedBindingForCommand
>[0],
cfg,
),
).toBe(false);
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(result).toEqual({ queuedFinal: true, counts: { tool: 0, block: 0, final: 0 } });
@@ -7,13 +7,20 @@ import {
normalizeCommandBody,
resolveTextCommand,
} from "../commands-registry.js";
import { shouldHandleTextCommands } from "../commands-text-routing.js";
import type { FinalizedMsgContext } from "../templating.js";
import { resolveCommandContextText } from "./context-text.js";
import { isExplicitSourceReplyCommand } from "./source-reply-delivery-mode.js";
export function shouldBypassPluginOwnedBindingForCommand(
ctx: FinalizedMsgContext,
cfg: OpenClawConfig,
): boolean {
// Command authorization is a trust boundary. Reject malformed runtime context
// before command-turn normalization can coerce a truthy value.
if (ctx.CommandAuthorized !== undefined && typeof ctx.CommandAuthorized !== "boolean") {
return false;
}
const commandTurn = resolveCommandTurnContext(ctx);
if (
(commandTurn.kind === "native" || commandTurn.kind === "text-slash") &&
@@ -24,15 +31,37 @@ export function shouldBypassPluginOwnedBindingForCommand(
if (isNativeCommandTurn(commandTurn) && commandTurn.authorized) {
return true;
}
if (!isExplicitSourceReplyCommand(ctx, cfg)) {
const isAuthorizedTextCommand =
(commandTurn.kind === "text-slash" && commandTurn.authorized) ||
(commandTurn.kind === "normal" &&
typeof ctx.CommandAuthorized === "boolean" &&
ctx.CommandAuthorized);
if (
!isAuthorizedTextCommand ||
!shouldHandleTextCommands({
cfg,
surface: ctx.Surface ?? ctx.Provider ?? "",
commandSource: ctx.CommandSource,
})
) {
return false;
}
const commandBody = normalizeCommandBody(commandTurn.body ?? ctx.CommandBody ?? "", {
const commandBody = normalizeCommandBody(commandTurn.body ?? resolveCommandContextText(ctx), {
botUsername: ctx.BotUsername,
});
if (!commandBody.startsWith("/")) {
return false;
}
if (
matchPluginCommand(commandBody, {
channel: normalizeOptionalString(ctx.Surface ?? ctx.Provider),
})
) {
return true;
}
if (!isExplicitSourceReplyCommand(ctx, cfg)) {
return false;
}
if (resolveTextCommand(commandBody)) {
return true;
}
@@ -45,9 +74,5 @@ export function shouldBypassPluginOwnedBindingForCommand(
) {
return true;
}
return Boolean(
matchPluginCommand(commandBody, {
channel: normalizeOptionalString(ctx.Surface ?? ctx.Provider),
}),
);
return false;
}
@@ -134,7 +134,7 @@ const sessionStoreMocks = vi.hoisted(() => ({
currentEntry: undefined as Record<string, unknown> | undefined,
entriesBySessionKey: new Map<string, Record<string, unknown>>(),
loadSessionEntry: vi.fn((..._args: unknown[]) => sessionStoreMocks.currentEntry),
loadSessionStoreEntry: vi.fn(() => sessionStoreMocks.currentEntry),
loadSessionStoreEntry: vi.fn((..._args: unknown[]) => sessionStoreMocks.currentEntry),
loadSessionStore: vi.fn(() => ({})),
readSessionEntry: vi.fn(() => sessionStoreMocks.currentEntry),
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
@@ -313,10 +313,16 @@ const conversationBindingMocks = vi.hoisted(() => {
if (!conversationId) {
return null;
}
const rawThreadParentId = resolveTarget(channel, params.ctx.ThreadParentId);
const explicitThreadParentId =
channel === "discord" && rawThreadParentId && !rawThreadParentId.includes(":")
? `channel:${rawThreadParentId}`
: rawThreadParentId;
const parentConversationId =
threadId && baseConversationId && baseConversationId !== threadId
explicitThreadParentId ??
(threadId && baseConversationId && baseConversationId !== threadId
? baseConversationId
: resolveTarget(channel, params.ctx.ThreadParentId);
: undefined);
return {
channel,
accountId: resolveAccountId(params.ctx, params.cfg, channel),
+130 -23
View File
@@ -103,6 +103,7 @@ import {
takeCommandSessionMetadataChanges,
type CommandSessionMetadataChange,
} from "./command-session-metadata.js";
import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js";
import { capturePendingConversationTurnReply } from "./conversation-turn-capture.js";
import {
DispatchReplyOperationAbortedError,
@@ -710,6 +711,7 @@ async function dispatchReplyFromConfigInner(
mirror?: boolean;
kind?: ReplyDispatchKind;
responsePrefixContext?: ResponsePrefixContext;
sessionKey?: string;
},
) => {
if (!shouldRouteToOriginating || !routeReplyChannel || !routeReplyTo || !routeReplyRuntime) {
@@ -720,15 +722,17 @@ async function dispatchReplyFromConfigInner(
// runtime that produced this payload, so agent_end and message delivery
// hooks expose the same canonical key for native command redirects.
const agentRuntimeSessionKey =
ctx.CommandSource === "native"
options?.sessionKey ??
(ctx.CommandSource === "native"
? (resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey)
: ctx.SessionKey;
: ctx.SessionKey);
return await routeReplyRuntime.routeReply({
payload,
channel: routeReplyChannel,
to: routeReplyTo,
sessionKey: agentRuntimeSessionKey,
policySessionKey: resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey,
policySessionKey:
options?.sessionKey ?? resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey,
policyConversationType: resolveRoutedPolicyConversationType(ctx),
accountId: routedReplyAccountId,
requesterSenderId: ctx.SenderId,
@@ -782,12 +786,37 @@ async function dispatchReplyFromConfigInner(
}
};
type PluginBindingTranscriptOwner = {
agentId: string;
expectedSessionId?: string;
sessionKey: string;
transcriptWriteBlocked?: true;
};
const deliverBindingPayload = async (
payload: ReplyPayload,
mode: "additive" | "terminal",
transcriptOwner?: PluginBindingTranscriptOwner,
): Promise<boolean> => {
const result = await routeReplyToOriginating(payload, {
// Metadata is delivery-specific. Keep it off the plugin-owned payload so a
// reused reply object cannot carry a stale transcript owner into a later turn.
const bindingPayload = setReplyPayloadMetadata(
copyReplyPayloadMetadata(payload, { ...payload }),
{
sourceReplyTranscriptMirror: transcriptOwner
? {
sessionKey: transcriptOwner.sessionKey,
agentId: transcriptOwner.agentId,
...(transcriptOwner.expectedSessionId
? { expectedSessionId: transcriptOwner.expectedSessionId }
: {}),
...(transcriptOwner.transcriptWriteBlocked ? { transcriptWriteBlocked: true } : {}),
}
: undefined,
},
);
const result = await routeReplyToOriginating(bindingPayload, {
kind: mode === "terminal" ? "final" : "tool",
sessionKey: transcriptOwner?.sessionKey,
});
if (result) {
if (!result.ok) {
@@ -799,36 +828,106 @@ async function dispatchReplyFromConfigInner(
}
markInboundDedupeReplayUnsafe();
return mode === "additive"
? dispatcher.sendToolResult(payload)
: dispatcher.sendFinalReply(payload);
? dispatcher.sendToolResult(bindingPayload)
: dispatcher.sendFinalReply(bindingPayload);
};
const sendBindingNotice = async (
payload: ReplyPayload,
mode: "additive" | "terminal",
transcriptOwner?: PluginBindingTranscriptOwner,
): Promise<boolean> => {
if (suppressAutomaticSourceDelivery) {
return false;
}
return await deliverBindingPayload(payload, mode);
return await deliverBindingPayload(payload, mode, transcriptOwner);
};
const pluginOwnedBindingRecord =
inboundClaimContext.conversationId && inboundClaimContext.channelId
? resolveConversationBindingRecord({
channel: inboundClaimContext.channelId,
accountId:
inboundClaimContext.accountId ??
((
cfg.channels as Record<string, { defaultAccount?: unknown } | undefined> | undefined
)?.[inboundClaimContext.channelId]?.defaultAccount as string | undefined) ??
"default",
conversationId: inboundClaimContext.conversationId,
parentConversationId: inboundClaimContext.parentConversationId,
})
: null;
// Hook contexts use transport-native ids (for example Slack `U123`), while
// binding records use the channel's canonical target (`user:U123`). Resolve
// through the binding contract instead of reusing the hook projection.
const pluginBindingConversation = resolveConversationBindingContextFromMessage({ cfg, ctx });
const pluginOwnedBindingRecord = pluginBindingConversation
? resolveConversationBindingRecord({
channel: pluginBindingConversation.channel,
accountId: pluginBindingConversation.accountId,
conversationId: pluginBindingConversation.conversationId,
parentConversationId: pluginBindingConversation.parentConversationId,
})
: null;
const pluginOwnedBinding = isPluginOwnedSessionBindingRecord(pluginOwnedBindingRecord)
? toPluginConversationBinding(pluginOwnedBindingRecord)
: null;
const pluginBindingSessionKey = normalizeOptionalString(
pluginOwnedBindingRecord?.targetSessionKey,
);
const persistPluginBindingUserTurn = async (): Promise<
PluginBindingTranscriptOwner | undefined
> => {
const recorder = params.replyOptions?.userTurnTranscriptRecorder;
if (!recorder || !pluginBindingSessionKey) {
return undefined;
}
const targetAgentId = resolveSessionAgentId({
sessionKey: pluginBindingSessionKey,
config: cfg,
fallbackAgentId: ctx.AgentId,
});
const blockedOwner = (expectedSessionId?: string): PluginBindingTranscriptOwner => ({
agentId: targetAgentId,
sessionKey: pluginBindingSessionKey,
...(expectedSessionId ? { expectedSessionId } : {}),
transcriptWriteBlocked: true,
});
if (recorder.hasPersisted()) {
return blockedOwner();
}
let attemptedSessionId: string | undefined;
let lastOwner: PluginBindingTranscriptOwner | undefined;
for (let attempt = 0; attempt < 2; attempt += 1) {
const targetSessionStoreEntry = resolveSessionStoreLookup(
{
...ctx,
CommandTargetSessionKey: undefined,
SessionKey: pluginBindingSessionKey,
},
cfg,
);
const targetSessionEntry = targetSessionStoreEntry.entry;
if (!targetSessionEntry || targetSessionEntry.sessionId === attemptedSessionId) {
break;
}
attemptedSessionId = targetSessionEntry.sessionId;
lastOwner = {
agentId: targetAgentId,
expectedSessionId: targetSessionEntry.sessionId,
sessionKey: pluginBindingSessionKey,
};
const result = await recorder.persistApproved({
target: {
sessionId: targetSessionEntry.sessionId,
sessionKey: pluginBindingSessionKey,
sessionEntry: targetSessionEntry,
...(targetSessionStoreEntry.store ? { sessionStore: targetSessionStoreEntry.store } : {}),
storePath: targetSessionStoreEntry.storePath,
agentId: targetAgentId,
cwd: resolveAgentWorkspaceDir(cfg, targetAgentId),
config: cfg,
},
expectedSessionId: targetSessionEntry.sessionId,
retryIfUnpersisted: true,
});
if (result) {
return lastOwner;
}
}
if (!lastOwner) {
recorder.markBlocked();
return blockedOwner();
}
recorder.markBlocked();
logVerbose(`plugin-bound user-turn persistence skipped after the target session changed`);
return blockedOwner(lastOwner.expectedSessionId);
};
// Resolve automatic source-delivery suppression early so every outbound path
// below (plugin-binding notices, fast-abort, normal dispatch) honors it. The
@@ -1245,19 +1344,23 @@ async function dispatchReplyFromConfigInner(
? ({ status: "no_handler" } as const)
: ({ status: "missing_plugin" } as const);
})();
if (isPreDispatchOperationAborted()) {
return finishReplyOperationAbortedDispatch();
}
switch (targetedClaimOutcome.status) {
case "handled": {
const transcriptOwner = await persistPluginBindingUserTurn();
if (targetedClaimOutcome.result.reply && shouldDeliverPluginBindingReply) {
// A bound plugin's reply is the explicit output for this claimed turn,
// not an automatic agent final; message-tool-only suppression must not
// turn normal user-request bindings into silent channel responses.
// Ambient room events keep the same privacy guard as final replies.
await deliverBindingPayload(targetedClaimOutcome.result.reply, "terminal");
await deliverBindingPayload(
targetedClaimOutcome.result.reply,
"terminal",
transcriptOwner,
);
}
markIdle("plugin_binding_dispatch");
recordProcessed("completed", { reason: "plugin-bound-handled" });
@@ -1302,9 +1405,11 @@ async function dispatchReplyFromConfigInner(
break;
}
case "declined": {
const transcriptOwner = await persistPluginBindingUserTurn();
await sendBindingNotice(
{ text: buildPluginBindingDeclinedText(pluginOwnedBinding) },
"terminal",
transcriptOwner,
);
markIdle("plugin_binding_declined");
recordProcessed("completed", { reason: "plugin-bound-declined" });
@@ -1316,12 +1421,14 @@ async function dispatchReplyFromConfigInner(
});
}
case "error": {
const transcriptOwner = await persistPluginBindingUserTurn();
logVerbose(
`plugin-bound inbound claim failed for ${pluginOwnedBinding.pluginId}: ${targetedClaimOutcome.error}`,
);
await sendBindingNotice(
{ text: buildPluginBindingErrorText(pluginOwnedBinding) },
"terminal",
transcriptOwner,
);
markIdle("plugin_binding_error");
recordProcessed("completed", { reason: "plugin-bound-error" });
@@ -1,5 +1,5 @@
// Loads isolated get-reply modules for tests that need fresh mocked state.
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { importFreshModule } from "../../plugin-sdk/test-fixtures.js";
type GetReplyModule = typeof import("./get-reply.js");
@@ -0,0 +1,74 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import type {
UserTurnTranscriptRecorder,
UserTurnTranscriptTarget,
} from "../../sessions/user-turn-transcript.types.js";
import { createReplyRestartRecoveryClaimController } from "./restart-recovery-claim.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("createReplyRestartRecoveryClaimController", () => {
it("retargets durable user-turn admission to the prepared reply session", async () => {
const root = tempDirs.make("openclaw-reply-admission-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "plugin-binding:codex:target";
const sessionId = "bound-session-id";
const entry = { sessionId, updatedAt: Date.now() };
await replaceSessionEntry({ storePath, sessionKey }, entry);
let persistedTarget: UserTurnTranscriptTarget | undefined;
const persistApproved = vi.fn<UserTurnTranscriptRecorder["persistApproved"]>(async (params) => {
persistedTarget =
typeof params?.target === "function" ? await params.target() : params?.target;
return {
appended: true,
message: { role: "user", content: "hello", timestamp: Date.now() },
messageId: "user-turn-1",
sessionEntry: entry,
sessionFile: "sqlite:bound-session-id",
};
});
const recorder = {
message: undefined,
resolveMessage: async () => undefined,
markRuntimePersistencePending: () => {},
markRuntimePersisted: () => {},
markBlocked: () => {},
hasPersisted: () => false,
isBlocked: () => false,
hasRuntimePersistencePending: () => false,
waitForRuntimePersistence: async () => {},
persistApproved,
persistBlocked: async () => undefined,
persistFallback: async () => undefined,
} satisfies UserTurnTranscriptRecorder;
const controller = createReplyRestartRecoveryClaimController({
getEntry: () => entry,
getSessionId: () => sessionId,
isRestartAbort: () => false,
resolveDeliveryContext: () => undefined,
resolveUserTurnTarget: (target) => ({
...target,
sessionEntry: target.entry,
agentId: "main",
}),
sessionKey,
setEntry: () => {},
storePath,
});
await expect(controller.admitUserTurn(recorder)).resolves.toBe("admitted");
expect(persistApproved).toHaveBeenCalledWith(
expect.objectContaining({ expectedSessionId: sessionId }),
);
expect(persistedTarget).toMatchObject({
sessionId,
sessionKey,
storePath,
agentId: "main",
});
});
});
+27 -2
View File
@@ -13,7 +13,10 @@ import type {
SessionTranscriptTurnLifecyclePatch,
} from "../../config/sessions/session-transcript-turn-lifecycle.types.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.types.js";
import type {
UserTurnTranscriptRecorder,
UserTurnTranscriptTarget,
} from "../../sessions/user-turn-transcript.types.js";
import type { DeliveryContext } from "../../utils/delivery-context.shared.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
@@ -140,6 +143,12 @@ export function createReplyRestartRecoveryClaimController(params: {
resolveDeliveryContext: (entry: SessionEntry | undefined) => DeliveryContext | undefined;
requesterAccountId?: unknown;
requesterSenderId?: unknown;
resolveUserTurnTarget?: (params: {
entry: SessionEntry;
sessionId: string;
sessionKey: string;
storePath: string;
}) => UserTurnTranscriptTarget | undefined;
sessionKey?: string;
setEntry: (entry: SessionEntry) => void;
sameChannelThreadRequired?: boolean;
@@ -162,6 +171,12 @@ export function createReplyRestartRecoveryClaimController(params: {
const expectedSessionState = buildExpectedSessionState(options.entry);
if (options.recorder && !options.recorder.hasPersisted()) {
const result = await options.recorder.persistApproved({
target: params.resolveUserTurnTarget?.({
entry: options.entry,
sessionId: options.sessionId,
sessionKey: options.sessionKey,
storePath: options.storePath,
}),
expectedSessionId: options.sessionId,
expectedSessionState,
sessionLifecyclePatch: options.patch,
@@ -191,7 +206,17 @@ export function createReplyRestartRecoveryClaimController(params: {
if (!recorder || recorder.hasPersisted()) {
return;
}
const result = await recorder.persistApproved({ expectedSessionId: sessionId });
const entry = params.getEntry();
const target =
entry && params.sessionKey && params.storePath
? params.resolveUserTurnTarget?.({
entry,
sessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
})
: undefined;
const result = await recorder.persistApproved({ target, expectedSessionId: sessionId });
if (!result) {
throw new Error("session changed before durable user-turn admission");
}
+48
View File
@@ -2079,6 +2079,54 @@ describe("initSessionState RawBody", () => {
expect(result.sessionKey).toBe(boundSessionKey);
});
it("does not apply a source admission id to a bound conversation target", async () => {
setMinimalCurrentConversationBindingRegistryForTests();
registerCurrentConversationBindingAdapterForTest({
channel: "slack",
accountId: "default",
});
const storePath = await createStorePath("openclaw-bound-admission-id-");
const sourceSessionKey = "agent:main:slack:source";
const sourceSessionId = "source-admission-session";
const boundSessionKey = "plugin-binding:codex:bound-target";
const boundSessionId = "bound-target-session";
await writeSessionStoreFast(storePath, {
[sourceSessionKey]: { sessionId: sourceSessionId, updatedAt: Date.now() },
[boundSessionKey]: { sessionId: boundSessionId, updatedAt: Date.now() },
});
await getSessionBindingService().bind({
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "user:U123",
},
});
const result = await initSessionState({
ctx: {
RawBody: "hello",
SessionKey: sourceSessionKey,
Provider: "slack",
Surface: "slack",
From: "slack:user:U123",
To: "user:U123",
OriginatingTo: "user:U123",
SenderId: "U123",
ChatType: "direct",
},
cfg: { session: { store: storePath } } as OpenClawConfig,
commandAuthorized: true,
expectedExistingSessionId: sourceSessionId,
pinExpectedExistingSession: true,
});
expect(result.sessionKey).toBe(boundSessionKey);
expect(result.sessionId).toBe(boundSessionId);
expect(result.isNewSession).toBe(false);
});
});
describe("initSessionState reset policy", () => {
+15 -3
View File
@@ -197,6 +197,7 @@ type InitSessionStateAttemptContext = {
agentId: string;
conversationBindingContext: ReturnType<typeof resolveSessionConversationBindingContext>;
isSystemEvent: boolean;
retargetedSession: boolean;
sessionCtxForState: MsgContext;
storePath: string;
};
@@ -301,6 +302,7 @@ function resolveInitSessionStateAttemptContext(
agentId,
conversationBindingContext,
isSystemEvent,
retargetedSession: sessionCtxForState !== ctx,
sessionCtxForState,
storePath: resolveStorePath(cfg.session?.store, { agentId }),
};
@@ -430,8 +432,14 @@ async function initSessionStateAttemptLocked(
lifecycleMutationIdentity: { sessionId: string; sessionKey: string } | undefined,
): Promise<InitSessionStateAttemptOutcome> {
const { ctx, cfg, commandAuthorized } = params;
const { agentId, conversationBindingContext, isSystemEvent, sessionCtxForState, storePath } =
attemptContext;
const {
agentId,
conversationBindingContext,
isSystemEvent,
retargetedSession,
sessionCtxForState,
storePath,
} = attemptContext;
const sessionCfg = cfg.session;
const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance);
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
@@ -611,7 +619,11 @@ async function initSessionStateAttemptLocked(
Boolean(entry?.sessionId) &&
typeof entry?.updatedAt === "number" &&
Number.isFinite(entry.updatedAt);
const expectedExistingSessionId = params.expectedExistingSessionId?.trim() || undefined;
// Gateway admission pins the source session. A conversation or command target owns a
// different session id, so applying the source constraint there rejects valid routing.
const expectedExistingSessionId = retargetedSession
? undefined
: params.expectedExistingSessionId?.trim() || undefined;
if (expectedExistingSessionId && entry?.sessionId !== expectedExistingSessionId) {
throw new Error(`session rebound for sessionKey: ${sessionKey}`);
}
@@ -1,7 +1,7 @@
/** Shared harness for sandbox media staging tests. */
import { join } from "node:path";
import { withTempHome as withTempHomeBase } from "openclaw/plugin-sdk/test-env";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withTempHome as withTempHomeBase } from "../plugin-sdk/test-env.js";
import type { MsgContext, TemplateContext } from "./templating.js";
export async function withSandboxMediaTempHome<T>(
@@ -1,10 +1,10 @@
import { beforeAll, describe, it } from "vitest";
/**
* Registry-backed channel contract shard installers.
*
* Installs surface, directory, threading, and plugin contract suites for bundled channel shards.
*/
import { expectChannelPluginContract } from "openclaw/plugin-sdk/channel-test-helpers";
import { beforeAll, describe, it } from "vitest";
import { expectChannelPluginContract } from "../../../../plugin-sdk/channel-test-helpers.js";
import {
getBundledChannelDirectoryPluginAsync,
getBundledChannelPluginAsync,
+1 -1
View File
@@ -4,10 +4,10 @@ import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { expectDefined } from "@openclaw/normalization-core";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { formatErrorMessage } from "../infra/errors.js";
import { withFileLock } from "../infra/file-lock.js";
import { root as createFsRoot, type Root as FsSafeRoot } from "../infra/fs-safe.js";
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
import { isPathInside } from "../security/scan-paths.js";
import { isRecord } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
@@ -3,7 +3,6 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveTimestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import type { AgentMessage } from "../../agents/runtime/index.js";
import {
acquireSessionWriteLock,
@@ -12,6 +11,7 @@ import {
import { redactTranscriptMessage } from "../../agents/transcript-redact.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { redactSecrets } from "../../logging/redact.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../shared/transcript-only-openclaw-assistant.js";
import { createSessionTranscriptHeader } from "./transcript-header.js";
import { serializeJsonlEntry, serializeJsonlLine, writeJsonlLines } from "./transcript-jsonl.js";
+1 -1
View File
@@ -1,7 +1,7 @@
// Provides config test helpers for temporary homes and fixture writes.
import fs from "node:fs/promises";
import path from "node:path";
import { withTempHome as withTempHomeBase } from "openclaw/plugin-sdk/test-env";
import { withTempHome as withTempHomeBase } from "../plugin-sdk/test-env.js";
import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import { resetConfigRuntimeState, type OpenClawConfig } from "./config.js";
+1 -1
View File
@@ -1,10 +1,10 @@
// Isolated agent test harness builds filesystem and config fixtures for cron agent tests.
import fs from "node:fs/promises";
import path from "node:path";
import { withTempHome as withTempHomeBase } from "openclaw/plugin-sdk/test-env";
import { replaceSessionEntry } from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withTempHome as withTempHomeBase } from "../plugin-sdk/test-env.js";
import type { CronJob } from "./types.js";
/** Runs a test callback with an isolated OpenClaw home for cron tests. */
+11
View File
@@ -376,6 +376,17 @@ describe("Dockerfile", () => {
);
});
it("keeps build-stage workspace packages readable by non-root live tests", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
const sourceCopyIndex = dockerfile.indexOf("COPY . .");
const readabilityIndex = dockerfile.indexOf("RUN chmod -R a+rX /app");
const buildIndex = dockerfile.indexOf("pnpm build:docker");
expect(sourceCopyIndex).toBeGreaterThan(-1);
expect(readabilityIndex).toBeGreaterThan(sourceCopyIndex);
expect(readabilityIndex).toBeLessThan(buildIndex);
});
it("keeps runtime workspace templates in final images", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
const runtimeStageIndex = dockerfile.lastIndexOf("FROM base-runtime");
+93 -37
View File
@@ -5,6 +5,8 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { renderCatFacePngBase64 } from "../../test/helpers/live-image-probe.js";
import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import type { ChannelOutboundContext } from "../channels/plugins/types.adapters.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
@@ -13,12 +15,15 @@ import { isTruthyEnvValue } from "../infra/env.js";
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import { findBundledPluginMetadataById } from "../plugins/bundled-plugin-metadata.js";
import { pluginCommands } from "../plugins/command-registry-state.js";
import { getCurrentPluginConversationBinding } from "../plugins/conversation-binding.js";
import { seedPluginConversationBindingApprovalForTest } from "../plugins/conversation-binding.test-fixtures.js";
import { clearPluginLoaderCache } from "../plugins/loader.test-fixtures.js";
import {
pinActivePluginChannelRegistry,
releasePinnedPluginChannelRegistry,
resetPluginRuntimeStateForTest,
} from "../plugins/runtime.js";
import { clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { extractFirstTextBlock } from "../shared/chat-message-content.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
@@ -179,7 +184,7 @@ async function waitForAgentRunOk(
runId: string,
context: string,
): Promise<void> {
let result: { status?: string };
let result: { status?: string; error?: unknown };
try {
result = await client.request(
"agent.wait",
@@ -191,7 +196,7 @@ async function waitForAgentRunOk(
throw new Error(`${context}: agent.wait error for ${runId}: ${message}`, { cause: error });
}
if (result?.status !== "ok") {
throw new Error(`${context}: agent.wait failed for ${runId}: status=${String(result?.status)}`);
throw new Error(`${context}: agent.wait failed for ${runId}: ${JSON.stringify(result)}`);
}
}
@@ -307,36 +312,6 @@ function resolveBoundSessionKey(params: {
return binding.targetSessionKey;
}
async function writePluginBindingApproval(params: {
homeDir: string;
pluginRoot: string;
channel: string;
accountId: string;
}): Promise<void> {
const openclawDir = path.join(params.homeDir, ".openclaw");
await fs.mkdir(openclawDir, { recursive: true });
await fs.writeFile(
path.join(openclawDir, "plugin-binding-approvals.json"),
`${JSON.stringify(
{
version: 1,
approvals: [
{
pluginRoot: params.pluginRoot,
pluginId: "codex",
pluginName: "Codex",
channel: params.channel,
accountId: params.accountId,
approvedAt: Date.now(),
},
],
},
null,
2,
)}\n`,
);
}
async function writeGatewayConfig(params: {
configPath: string;
model: string;
@@ -346,6 +321,8 @@ async function writeGatewayConfig(params: {
workspace: string;
}): Promise<void> {
const modelProvider = params.modelProvider?.trim() || "codex";
const usesApiKeyAuth =
modelProvider === "openai" && process.env.OPENCLAW_LIVE_CODEX_HARNESS_AUTH === "api-key";
const cfg: OpenClawConfig = {
gateway: {
mode: "local",
@@ -379,6 +356,26 @@ async function writeGatewayConfig(params: {
sandbox: { mode: "off" },
},
},
...(usesApiKeyAuth
? {
auth: {
profiles: { "openai:default": { provider: "openai", mode: "api_key" } },
order: { openai: ["openai:default"] },
},
secrets: { providers: { default: { source: "env" } } },
models: {
mode: "merge",
providers: {
openai: {
api: "openai-responses",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
baseUrl: "https://api.openai.com/v1",
models: [],
},
},
},
}
: {}),
};
await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`);
}
@@ -452,6 +449,31 @@ describeLive("gateway live (native Codex conversation binding)", () => {
setTestEnvValue("OPENCLAW_SKIP_CRON", "1");
setTestEnvValue("OPENCLAW_SKIP_GMAIL_WATCHER", "1");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
if (process.env.OPENCLAW_LIVE_CODEX_HARNESS_AUTH === "api-key") {
const apiKey = process.env.OPENAI_API_KEY?.trim();
if (!apiKey) {
throw new Error("API-key bind mode requires OPENAI_API_KEY.");
}
// This isolated test database is removed in finally. Persisting the prepared key here
// avoids coupling the binding proof to the outer gateway's secret-snapshot lifecycle.
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: apiKey,
},
},
order: { openai: ["openai:default"] },
},
resolveDefaultAgentDir({}),
);
}
// The live process imports against its original home before this test switches to
// an isolated state dir. Force gateway startup to materialize that exact store.
clearSecretsRuntimeSnapshot();
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
let client: Awaited<ReturnType<typeof connectTestGatewayClient>> | undefined;
let pinnedChannelRegistry:
@@ -476,9 +498,10 @@ describeLive("gateway live (native Codex conversation binding)", () => {
pinActivePluginChannelRegistry(channelRegistry);
pinnedChannelRegistry = channelRegistry;
await writePluginBindingApproval({
homeDir: tempHome,
seedPluginConversationBindingApprovalForTest({
pluginRoot: resolveCodexPluginRoot(),
pluginId: "codex",
pluginName: "Codex",
channel: "slack",
accountId,
});
@@ -498,17 +521,44 @@ describeLive("gateway live (native Codex conversation binding)", () => {
});
const bindReply = await waitForOutboundText({
replies: outboundReplies,
contains: "Bound this conversation to Codex thread",
contains: "Bound this conversation to",
timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
});
expect(bindReply.matchedText).toContain("Bound this conversation to Codex thread");
expect(bindReply.matchedText).toContain("The next message will initialize it.");
const boundSessionKey = resolveBoundSessionKey({
channel: "slack",
accountId,
conversationId,
});
logCodexBindStep(`binding resolved to ${boundSessionKey}`);
let commandReplyCount = bindReply.outboundTexts.length;
const initialNonce = randomBytes(4).toString("hex").toUpperCase();
const expectedReply = `CODEX-BIND-${initialNonce}`;
await sendChatAndWait({
client: activeClient,
sessionKey,
idempotencyKey: `idem-codex-bound-text-${randomUUID()}`,
context: "bound text turn",
message: `Reply with exactly this token and nothing else: ${expectedReply}`,
originatingChannel: "slack",
originatingTo: conversationId,
originatingAccountId: accountId,
deliver: true,
});
const textReply = await waitForOutboundText({
replies: outboundReplies,
contains: expectedReply,
timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
});
expect(textReply.matchedText).toContain(expectedReply);
const currentConversationBinding = await getCurrentPluginConversationBinding({
pluginRoot: resolveCodexPluginRoot(),
conversation: { channel: "slack", accountId, conversationId },
});
expect(currentConversationBinding).not.toBeNull();
let commandReplyCount = textReply.outboundTexts.length;
const sendCodexCommand = async (message: string, contains: string, timeoutMs = 60_000) => {
await sendChatAndWait({
@@ -538,6 +588,12 @@ describeLive("gateway live (native Codex conversation binding)", () => {
CODEX_BIND_REQUEST_TIMEOUT_MS,
);
await sendCodexCommand("/codex models", "Codex models:", CODEX_BIND_REQUEST_TIMEOUT_MS);
const initializedBinding = await sendCodexCommand(
"/codex binding",
"Codex conversation binding:",
CODEX_BIND_REQUEST_TIMEOUT_MS,
);
expect(initializedBinding.matchedText).not.toContain("- Thread: unknown");
await sendCodexCommand("/codex fast on", "Codex fast mode enabled.");
await sendCodexCommand("/codex fast status", "Codex fast mode: on.");
await sendCodexCommand("/codex permissions default", "Codex permissions set to default.");
@@ -8,6 +8,7 @@ import {
} from "./gateway-codex-harness.command-evidence.live-helpers.js";
import {
buildCodexHarnessLargeOutputCommand,
CODEX_HARNESS_MAX_LARGE_OUTPUT_BYTES,
EXPECTED_CODEX_MODELS_COMMAND_TEXT,
EXPECTED_CODEX_STATUS_COMMAND_TEXT,
isExpectedCodexModelsCommandText,
@@ -42,11 +43,12 @@ describe("gateway codex harness live helpers", () => {
it("builds an exact large-output command without escape-sensitive newlines", () => {
const command = buildCodexHarnessLargeOutputCommand({
commandMarker: "OPENCLAW-LARGE-OUTPUT-ABC",
outputBytes: 1_000_000,
outputBytes: CODEX_HARNESS_MAX_LARGE_OUTPUT_BYTES,
});
expect(command).toContain('"OPENCLAW-LARGE-OUTPUT-ABC|"');
expect(command).toContain(".slice(0,1000000)");
expect(command).toContain(".slice(0,800000)");
expect(CODEX_HARNESS_MAX_LARGE_OUTPUT_BYTES).toBeLessThan(1024 * 1024);
expect(command).not.toContain("\\n");
expect(command).not.toContain("\n");
});
@@ -1,6 +1,10 @@
import { extractShellWrapperInlineCommand } from "../infra/shell-wrapper-resolution.js";
import { splitShellArgs } from "../utils/shell-argv.js";
// Codex 0.144.6 retains at most 1 MiB per exec stream. Leave headroom so this
// soak measures compaction behavior instead of the dependency's output boundary.
export const CODEX_HARNESS_MAX_LARGE_OUTPUT_BYTES = 800_000;
/**
* Text matchers shared by live Codex harness tests.
*
+19 -2
View File
@@ -26,6 +26,7 @@ import {
import { requireSuccessfulNativeCommandCompactionEvidence } from "./gateway-codex-harness.command-evidence.live-helpers.js";
import {
buildCodexHarnessLargeOutputCommand,
CODEX_HARNESS_MAX_LARGE_OUTPUT_BYTES,
EXPECTED_CODEX_MODELS_COMMAND_TEXT,
EXPECTED_CODEX_STATUS_COMMAND_TEXT,
isExpectedCodexStatusCommandText,
@@ -97,7 +98,7 @@ const CODEX_HARNESS_LARGE_OUTPUT_BYTES = resolveBoundedPositiveIntEnv(
"OPENCLAW_LIVE_CODEX_HARNESS_LARGE_OUTPUT_BYTES",
process.env.OPENCLAW_LIVE_CODEX_HARNESS_LARGE_OUTPUT_BYTES,
300_000,
1_000_000,
CODEX_HARNESS_MAX_LARGE_OUTPUT_BYTES,
100_000,
);
const CODEX_HARNESS_SUBAGENT_COUNT = resolveBoundedPositiveIntEnv(
@@ -433,7 +434,7 @@ async function writeLiveGatewayConfig(params: {
token: string;
workspace: string;
}): Promise<void> {
parseModelKey(params.modelKey);
const parsedModel = parseModelKey(params.modelKey);
const cfg: OpenClawConfig = {
gateway: {
mode: "local",
@@ -502,6 +503,22 @@ async function writeLiveGatewayConfig(params: {
},
],
},
...(CODEX_HARNESS_AUTH_MODE === "api-key" && parsedModel.provider === "openai"
? {
secrets: { providers: { default: { source: "env" } } },
models: {
mode: "merge",
providers: {
openai: {
api: "openai-responses",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
baseUrl: "https://api.openai.com/v1",
models: [],
},
},
},
}
: {}),
};
await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`);
}
@@ -1,6 +1,6 @@
// Gateway auth rate-limit serialization.
// Serializes limiter attempts per IP/scope so concurrent failures count correctly.
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
import { AUTH_RATE_LIMIT_SCOPE_DEFAULT, normalizeRateLimitClientIp } from "./auth-rate-limit.js";
const pendingAttempts = new KeyedAsyncQueue();
@@ -1,5 +1,5 @@
import { expectDefined } from "@openclaw/normalization-core";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import { getReplyPayloadMetadata, type ReplyPayload } from "../../auto-reply/reply-payload.js";
import {
appendLocalMediaParentRoots,
getAgentScopedMediaLocalRoots,
@@ -34,6 +34,87 @@ type DeliveredReply = {
kind: "block" | "final";
};
type TranscriptMirrorOwner = {
agentId?: string;
expectedSessionId?: string;
sessionKey: string;
};
type TranscriptMirrorResolution =
| { kind: "none" }
| { kind: "invalid" }
| { kind: "blocked"; owner: TranscriptMirrorOwner }
| { kind: "owner"; owner: TranscriptMirrorOwner };
function resolveTranscriptMirrorOwner(
payloads: readonly ReplyPayload[],
): TranscriptMirrorResolution {
if (payloads.length === 0) {
return { kind: "none" };
}
const owners = payloads.map(
(payload) => getReplyPayloadMetadata(payload)?.sourceReplyTranscriptMirror,
);
// Older source-reply mirrors have neither field and keep their existing source-session
// behavior. Either field opts the batch into binding-owned transcript handling.
if (
owners.every(
(owner) => owner?.expectedSessionId === undefined && !owner?.transcriptWriteBlocked,
)
) {
return { kind: "none" };
}
const first = owners[0];
if (!first) {
return { kind: "invalid" };
}
const sessionKey = first.sessionKey.trim();
const expectedSessionId = first.expectedSessionId?.trim();
if (first.transcriptWriteBlocked) {
if (
!sessionKey ||
owners.some(
(owner) =>
!owner?.transcriptWriteBlocked ||
owner.sessionKey.trim() !== sessionKey ||
owner.expectedSessionId?.trim() !== expectedSessionId ||
owner.agentId !== first.agentId,
)
) {
return { kind: "invalid" };
}
return {
kind: "blocked",
owner: {
sessionKey,
...(expectedSessionId ? { expectedSessionId } : {}),
...(first.agentId ? { agentId: first.agentId } : {}),
},
};
}
if (
!sessionKey ||
!expectedSessionId ||
owners.some(
(owner) =>
owner?.sessionKey.trim() !== sessionKey ||
owner.expectedSessionId?.trim() !== expectedSessionId ||
owner.agentId !== first.agentId ||
owner.transcriptWriteBlocked === true,
)
) {
return { kind: "invalid" };
}
return {
kind: "owner",
owner: {
sessionKey,
expectedSessionId,
...(first.agentId ? { agentId: first.agentId } : {}),
},
};
}
function buildChatSendBtwSideResult(deliveredReplies: readonly DeliveredReply[]) {
const replies = deliveredReplies.map((entry) => entry.payload).filter(isBtwReplyPayload);
const text = replies
@@ -103,6 +184,11 @@ export async function finalizeChatSendNonAgentReplies(params: {
foldCommandBlocks,
suppressReplies,
});
const transcriptMirrorResolution = resolveTranscriptMirrorOwner(rawFinalPayloads);
const transcriptMirrorOwner =
transcriptMirrorResolution.kind === "owner" || transcriptMirrorResolution.kind === "blocked"
? transcriptMirrorResolution.owner
: undefined;
const finalPayloads = await normalizeWebchatReplyMediaPathsForDisplay({
cfg,
sessionKey,
@@ -110,13 +196,53 @@ export async function finalizeChatSendNonAgentReplies(params: {
accountId,
payloads: rawFinalPayloads,
});
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
sessionKey,
sessionLoadOptions,
const requestedTranscriptSession = transcriptMirrorOwner
? loadSessionEntry(transcriptMirrorOwner.sessionKey, {
...sessionLoadOptions,
...(transcriptMirrorOwner.agentId ? { agentId: transcriptMirrorOwner.agentId } : {}),
})
: undefined;
// Binding-owned payloads already retargeted the user turn. Keep the assistant
// beside it only when that durable target still exists. Never fall back to the
// source transcript after ownership metadata appears on any final payload.
const useTranscriptMirrorOwner = Boolean(
transcriptMirrorResolution.kind === "owner" &&
transcriptMirrorOwner &&
requestedTranscriptSession?.entry?.sessionId === transcriptMirrorOwner.expectedSessionId,
);
if (transcriptMirrorResolution.kind === "owner" && !useTranscriptMirrorOwner) {
context.logGateway.warn(
`webchat transcript append skipped: binding-owned session changed before finalization`,
);
}
if (transcriptMirrorResolution.kind === "invalid") {
context.logGateway.warn(
`webchat transcript append skipped: inconsistent binding-owned transcript metadata`,
);
}
if (transcriptMirrorResolution.kind === "blocked") {
context.logGateway.warn(
`webchat transcript append skipped: binding-owned user turn was not persisted`,
);
}
const canAppendAssistantTranscript =
transcriptMirrorResolution.kind === "none" || useTranscriptMirrorOwner;
const transcriptSessionKey =
useTranscriptMirrorOwner && transcriptMirrorOwner
? transcriptMirrorOwner.sessionKey
: sessionKey;
const transcriptAgentId =
useTranscriptMirrorOwner && transcriptMirrorOwner
? (transcriptMirrorOwner.agentId ?? agentId)
: agentId;
const resolvedTranscriptSession =
useTranscriptMirrorOwner && requestedTranscriptSession
? requestedTranscriptSession
: loadSessionEntry(sessionKey, sessionLoadOptions);
const { storePath: latestStorePath, entry: latestEntry } = resolvedTranscriptSession;
const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId;
const mediaLocalRoots = appendLocalMediaParentRoots(
getAgentScopedMediaLocalRoots(cfg, agentId),
getAgentScopedMediaLocalRoots(cfg, transcriptAgentId),
latestStorePath ? [latestStorePath] : undefined,
);
const assistantContent = await buildAssistantDisplayContentFromReplyPayloads({
@@ -184,18 +310,18 @@ export async function finalizeChatSendNonAgentReplies(params: {
transcriptDisplayReply;
let message: Record<string, unknown> | undefined;
const shouldAppendAssistantTranscript = Boolean(
transcriptReply || persistedContentForAppend?.length,
canAppendAssistantTranscript && (transcriptReply || persistedContentForAppend?.length),
);
await persistUserTurnTranscript();
if (shouldAppendAssistantTranscript) {
const appended = await appendAssistantTranscriptMessage({
sessionKey,
sessionKey: transcriptSessionKey,
message: transcriptReply,
...(persistedContentForAppend?.length ? { content: persistedContentForAppend } : {}),
sessionId,
storePath: latestStorePath,
sessionFile: latestEntry?.sessionFile,
agentId,
agentId: transcriptAgentId,
createIfMissing: true,
idempotencyKey: clientRunId,
ttsSupplement: ttsSupplementMarker,
@@ -203,7 +203,11 @@ export function createChatSendReplyDispatch(params: {
logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
},
deliver: async (payload, info) => {
if (getReplyPayloadMetadata(payload)?.beforeAgentRunBlocked === true) {
const payloadMetadata = getReplyPayloadMetadata(payload);
if (
payloadMetadata?.beforeAgentRunBlocked === true ||
payloadMetadata?.sourceReplyTranscriptMirror?.transcriptWriteBlocked === true
) {
userTurnRecorder.markBlocked();
}
switch (info.kind) {
@@ -1087,6 +1087,176 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
});
it("persists non-agent plugin-bound replies in the binding-owned session", async () => {
await createTranscriptFixture("openclaw-chat-send-plugin-binding-history-");
const targetSessionKey = "plugin-binding:codex:history123";
mockState.finalPayload = setReplyPayloadMetadata(
{ text: "bound history reply" },
{
sourceReplyTranscriptMirror: {
sessionKey: targetSessionKey,
agentId: "main",
expectedSessionId: mockState.sessionId,
},
},
);
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-plugin-binding-history",
expectBroadcast: false,
});
expect(mockState.loadSessionEntryCalls).toContainEqual({
rawKey: targetSessionKey,
opts: { agentId: "main" },
});
const assistantUpdate = mockState.emittedTranscriptUpdates.find(
(update) => (update.message as { role?: unknown } | undefined)?.role === "assistant",
);
expect(assistantUpdate?.target).toMatchObject({
agentId: "main",
sessionKey: targetSessionKey,
});
});
it("does not cross a plugin-bound session rotation during finalization", async () => {
await createTranscriptFixture("openclaw-chat-send-plugin-binding-rotation-");
const targetSessionKey = "plugin-binding:codex:rotated";
mockState.finalPayload = setReplyPayloadMetadata(
{ text: "stale bound reply" },
{
sourceReplyTranscriptMirror: {
sessionKey: targetSessionKey,
agentId: "main",
expectedSessionId: "previous-bound-session",
},
},
);
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-plugin-binding-rotation",
expectBroadcast: false,
});
expect(
mockState.emittedTranscriptUpdates.some(
(update) => (update.message as { role?: unknown } | undefined)?.role === "assistant",
),
).toBe(false);
expect(context.logGateway.warn).toHaveBeenCalledWith(
"webchat transcript append skipped: binding-owned session changed before finalization",
);
});
it("keeps a twice-raced plugin-bound turn out of source history", async () => {
await createTranscriptFixture("openclaw-chat-send-plugin-binding-blocked-");
mockState.finalPayload = setReplyPayloadMetadata(
{ text: "live reply without durable turn" },
{
sourceReplyTranscriptMirror: {
sessionKey: "plugin-binding:codex:blocked",
agentId: "main",
transcriptWriteBlocked: true,
},
},
);
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-plugin-binding-blocked",
expectBroadcast: false,
});
expect(mockState.emittedTranscriptUpdates).toHaveLength(0);
expect(context.logGateway.warn).toHaveBeenCalledWith(
"webchat transcript append skipped: binding-owned user turn was not persisted",
);
});
it("does not fall back to source history for partial binding transcript metadata", async () => {
await createTranscriptFixture("openclaw-chat-send-plugin-binding-partial-");
const targetSessionKey = "plugin-binding:codex:partial";
mockState.dispatchedReplies = [
{
kind: "final",
payload: setReplyPayloadMetadata(
{ text: "bound reply" },
{
sourceReplyTranscriptMirror: {
sessionKey: targetSessionKey,
agentId: "main",
expectedSessionId: mockState.sessionId,
},
},
),
},
{ kind: "final", payload: { text: "derived reply without owner" } },
];
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-plugin-binding-partial",
expectBroadcast: false,
});
expect(
mockState.emittedTranscriptUpdates.some(
(update) => (update.message as { role?: unknown } | undefined)?.role === "assistant",
),
).toBe(false);
expect(context.logGateway.warn).toHaveBeenCalledWith(
"webchat transcript append skipped: inconsistent binding-owned transcript metadata",
);
});
it("keeps legacy source-reply mirror metadata on source history", async () => {
await createTranscriptFixture("openclaw-chat-send-source-mirror-legacy-");
mockState.finalPayload = setReplyPayloadMetadata(
{ text: "legacy source reply" },
{
sourceReplyTranscriptMirror: {
sessionKey: "main",
text: "legacy source reply",
idempotencyKey: "legacy-source-reply",
},
},
);
const respond = vi.fn();
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-source-mirror-legacy",
expectBroadcast: false,
});
const assistantUpdate = mockState.emittedTranscriptUpdates.find(
(update) => (update.message as { role?: unknown } | undefined)?.role === "assistant",
);
expect(assistantUpdate?.target).toMatchObject({
agentId: "main",
sessionKey: "main",
});
expect(context.logGateway.warn).not.toHaveBeenCalledWith(
"webchat transcript append skipped: inconsistent binding-owned transcript metadata",
);
});
it("registers tool-event recipients for clients advertising tool-events capability", async () => {
await createTranscriptFixture("openclaw-chat-send-tool-events-");
mockState.finalText = "ok";
+1 -1
View File
@@ -5,7 +5,6 @@ import {
normalizeOptionalString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import {
ErrorCodes,
errorShape,
@@ -51,6 +50,7 @@ import {
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { extractToolPayload } from "../../plugin-sdk/tool-payload.js";
import { normalizePollInput } from "../../polls.js";
import { normalizeAgentId } from "../../routing/session-key.js";
+1 -1
View File
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
// OpenClaw gateway methods host the setup/repair conversation for clients.
import {
buildSystemAgentSessionInvalidatedErrorDetails,
@@ -18,6 +17,7 @@ import {
SYSTEM_AGENT_APPROVAL_TIMEOUT_MS,
type SystemAgentApprovalRequestPayload,
} from "../../infra/system-agent-approvals.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { enqueueCommandInLane, setCommandLaneConcurrency } from "../../process/command-queue.js";
import { CommandLane } from "../../process/lanes.js";
import { defaultRuntime } from "../../runtime.js";
@@ -1,7 +1,9 @@
/** Factory for image providers with OpenAI-compatible generation/edit endpoints. */
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveGeneratedMediaMaxBytes } from "../media/configured-max-bytes.js";
import { resolveApiKeyForProvider } from "../plugin-sdk/provider-auth-runtime.js";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
@@ -11,9 +13,7 @@ import {
resolveProviderHttpRequestConfig,
resolveProviderOperationTimeoutMs,
sanitizeConfiguredModelProviderRequest,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveGeneratedMediaMaxBytes } from "../media/configured-max-bytes.js";
} from "../plugin-sdk/provider-http.js";
import {
parseOpenAiCompatibleImageResponse,
resolveInlineImageJsonResponseMaxBytes,
@@ -2,7 +2,7 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type { StreamFn } from "../../../agents/runtime/index.js";
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
import { createLazyImportLoader } from "../../../shared/lazy-promise.js";
import { streamSimple } from "../../stream.js";
type MoonshotThinkingType = "enabled" | "disabled";
type MoonshotThinkingKeep = "all";
@@ -17,13 +17,11 @@ const MOONSHOT_FIXED_SAMPLING_FIELDS = [
"presence_penalty",
"frequency_penalty",
] as const;
const llmRuntimeLoader = createLazyImportLoader(() => import("openclaw/plugin-sdk/llm"));
type MoonshotK27CodeModel = (typeof MOONSHOT_K2_7_CODE_MODEL_IDS)[number];
type MoonshotAlwaysThinkingModel = MoonshotK27CodeModel | "kimi-k3";
async function loadDefaultStreamFn(): Promise<StreamFn> {
const runtime = await llmRuntimeLoader.load();
return runtime.streamSimple;
return streamSimple;
}
function normalizeMoonshotThinkingType(value: unknown): MoonshotThinkingType | undefined {
+43
View File
@@ -1167,6 +1167,49 @@ describe("registerPluginCommand", () => {
expectUnsupportedBindingApiResult(result);
});
it("uses the stable originating target for plugin conversation commands", async () => {
const resolveCommandConversation = vi.fn(() => null);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
source: "test",
plugin: {
...createChannelTestPluginBase({ id: "slack", label: "Slack" }),
bindings: { resolveCommandConversation },
},
},
]),
);
const handler = vi.fn(async () => ({ text: "ok" }));
await executePluginCommand({
command: {
name: "control",
description: "Control a binding",
acceptsArgs: false,
handler,
pluginId: "demo-plugin",
},
channel: "slack",
senderId: "U123",
isAuthorizedSender: true,
commandBody: "/control",
config: {} as never,
from: "slack:U123",
to: "changed-runtime-target",
originatingTo: "user:U123",
accountId: "default",
});
expect(resolveCommandConversation).toHaveBeenCalledWith(
expect.objectContaining({
originatingTo: "user:U123",
commandTo: "changed-runtime-target",
}),
);
});
it("passes host session identity through to the plugin command context", async () => {
let receivedCtx:
| {
+4 -1
View File
@@ -128,6 +128,7 @@ function resolveBindingConversationFromCommand(params: {
senderId?: string;
from?: string;
to?: string;
originatingTo?: string;
accountId?: string;
messageThreadId?: string | number;
threadParentId?: string;
@@ -151,7 +152,7 @@ function resolveBindingConversationFromCommand(params: {
threadId: params.messageThreadId,
threadParentId: params.threadParentId,
senderId: params.senderId,
originatingTo: params.from,
originatingTo: params.originatingTo ?? params.from,
commandTo: params.to,
fallbackTo: params.to ?? params.from,
});
@@ -230,6 +231,7 @@ export async function executePluginCommand(params: {
config: OpenClawConfig;
from?: PluginCommandContext["from"];
to?: PluginCommandContext["to"];
originatingTo?: string;
accountId?: PluginCommandContext["accountId"];
messageThreadId?: PluginCommandContext["messageThreadId"];
threadParentId?: PluginCommandContext["threadParentId"];
@@ -288,6 +290,7 @@ export async function executePluginCommand(params: {
senderId,
from: params.from,
to: params.to,
originatingTo: params.originatingTo,
accountId: params.accountId,
messageThreadId: params.messageThreadId,
threadParentId: params.threadParentId,
+8 -8
View File
@@ -1,18 +1,18 @@
// TTS contract suites provide reusable text-to-speech plugin contract assertions.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createEmptyPluginRegistry,
pluginRegistrationContractRegistry,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { ResolvedTtsConfig, SpeechProviderPlugin } from "openclaw/plugin-sdk/speech-core";
import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { withEnv, withEnvAsync, withServer } from "openclaw/plugin-sdk/test-env";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AssistantMessage, Model } from "../../llm/types.js";
import {
createEmptyPluginRegistry,
pluginRegistrationContractRegistry,
setActivePluginRegistry,
} from "../../plugin-sdk/plugin-test-runtime.js";
import { withEnv, withEnvAsync, withServer } from "../../plugin-sdk/test-env.js";
import { resolveWorkspacePackagePublicModuleUrl } from "../../plugin-sdk/test-helpers/public-surface-loader.js";
import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js";
@@ -458,7 +458,7 @@ const loadTtsRuntime = createLazyRuntimeModule(
() => import(speechCoreRuntimeApiModuleId) as Promise<TtsRuntimeModule>,
);
const loadTtsCore = createLazyRuntimeModule(() => import("openclaw/plugin-sdk/speech-core"));
const loadTtsCore = createLazyRuntimeModule(() => import("../../plugin-sdk/speech-core.js"));
function createPrepareSimpleCompletionModelMock(): SummarizeTextDeps["prepareSimpleCompletionModel"] {
return vi.fn(async ({ provider, modelId }) => ({
@@ -532,7 +532,7 @@ function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConf
async function setupSummarizationMocks() {
({ summarizeText: summarizeTextCore } = await loadTtsCore());
({ completeSimple } = await import("openclaw/plugin-sdk/llm"));
({ completeSimple } = await import("../../plugin-sdk/llm.js"));
prepareSimpleCompletionModelMock = createPrepareSimpleCompletionModelMock();
requireApiKeyMock = vi.fn() as SummarizeTextDeps["requireApiKey"];
vi.mocked(completeSimple).mockResolvedValue(
@@ -1,5 +1,10 @@
/** Test-only reset for process-global plugin conversation binding state. */
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { resolveGlobalMap, resolveGlobalSingleton } from "../shared/global-singleton.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
type PluginBindingApprovalsDatabase = Pick<OpenClawStateKyselyDatabase, "plugin_binding_approvals">;
type PluginBindingGlobalState = {
fallbackNoticeBindingIds: Set<string>;
@@ -24,3 +29,38 @@ export function resetPluginConversationBindingStateForTest(): void {
state.approvalsSaveChain = Promise.resolve();
state.fallbackNoticeBindingIds.clear();
}
export function seedPluginConversationBindingApprovalForTest(params: {
pluginRoot: string;
pluginId: string;
pluginName?: string;
channel: string;
accountId: string;
approvedAt?: number;
}): void {
runOpenClawStateWriteTransaction(({ db }) => {
const approvalsDb = getNodeSqliteKysely<PluginBindingApprovalsDatabase>(db);
executeSqliteQuerySync(
db,
approvalsDb
.insertInto("plugin_binding_approvals")
.values({
plugin_root: params.pluginRoot,
channel: params.channel.trim().toLowerCase(),
account_id: params.accountId.trim() || "default",
plugin_id: params.pluginId,
plugin_name: params.pluginName ?? null,
approved_at: params.approvedAt ?? Date.now(),
})
.onConflict((conflict) =>
conflict.columns(["plugin_root", "channel", "account_id"]).doUpdateSet({
plugin_id: (eb) => eb.ref("excluded.plugin_id"),
plugin_name: (eb) => eb.ref("excluded.plugin_name"),
approved_at: (eb) => eb.ref("excluded.approved_at"),
}),
),
);
});
// Seeded rows must become visible even if another test loaded the process cache first.
resetPluginConversationBindingStateForTest();
}
+7 -14
View File
@@ -12,7 +12,10 @@ import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { resetPluginConversationBindingStateForTest } from "./conversation-binding.test-fixtures.js";
import {
resetPluginConversationBindingStateForTest,
seedPluginConversationBindingApprovalForTest,
} from "./conversation-binding.test-fixtures.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import type { PluginRegistry } from "./registry.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
@@ -446,19 +449,9 @@ function insertPluginBindingApprovalRow(params: {
accountId: string;
pluginId: string;
}): void {
runOpenClawStateWriteTransaction(({ db }) => {
const approvalsDb = getNodeSqliteKysely<PluginBindingApprovalsDatabase>(db);
executeSqliteQuerySync(
db,
approvalsDb.insertInto("plugin_binding_approvals").values({
plugin_root: params.pluginRoot,
channel: params.channel,
account_id: params.accountId,
plugin_id: params.pluginId,
plugin_name: null,
approved_at: 1,
}),
);
seedPluginConversationBindingApprovalForTest({
...params,
approvedAt: 1,
});
}
+62
View File
@@ -811,6 +811,68 @@ describe("user turn transcript persistence", () => {
]);
});
it("re-resolves the target after an explicitly retryable persistence miss", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-retry-");
const admittedTarget = createSqliteTranscriptTarget({ dir, sessionId: "admitted-session" });
let targetResolutionCount = 0;
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "persist me after the target rotates",
timestamp: 123,
},
target: () => {
targetResolutionCount += 1;
return targetResolutionCount === 1 ? undefined : admittedTarget;
},
updateMode: "none",
});
await expect(recorder.persistApproved({ retryIfUnpersisted: true })).resolves.toBeUndefined();
const persisted = await recorder.persistApproved({ retryIfUnpersisted: true });
expect(targetResolutionCount).toBe(2);
expect(persisted?.sessionFile).toBe(admittedTarget.sqliteMarker);
await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "persist me after the target rotates",
}),
]);
});
it("keeps concurrent persistence retries single-flight", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-concurrent-retry-");
const admittedTarget = createSqliteTranscriptTarget({ dir, sessionId: "admitted-session" });
let targetResolutionCount = 0;
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "persist me once after concurrent retries",
timestamp: 123,
},
target: () => {
targetResolutionCount += 1;
return targetResolutionCount === 1 ? undefined : admittedTarget;
},
updateMode: "none",
});
await expect(recorder.persistApproved({ retryIfUnpersisted: true })).resolves.toBeUndefined();
const [first, second] = await Promise.all([
recorder.persistApproved({ retryIfUnpersisted: true }),
recorder.persistApproved({ retryIfUnpersisted: true }),
]);
expect(targetResolutionCount).toBe(2);
expect(first?.sessionFile).toBe(admittedTarget.sqliteMarker);
expect(second?.sessionFile).toBe(admittedTarget.sqliteMarker);
await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "persist me once after concurrent retries",
}),
]);
});
it("waits for runtime persistence before deciding fallback ownership", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-pending-");
const target = createSqliteTranscriptTarget({ dir });
+20 -3
View File
@@ -567,6 +567,7 @@ export function createUserTurnTranscriptRecorder(
expectedSessionId?: string;
expectedSessionState?: SessionTranscriptTurnPersistOptions["expectedSessionState"];
sessionLifecyclePatch?: SessionTranscriptTurnPersistOptions["sessionLifecyclePatch"];
retryIfUnpersisted?: boolean;
}): Promise<UserTurnTranscriptPersistResult | undefined> => {
if (options.skipWhenBlocked && blocked) {
return undefined;
@@ -578,9 +579,19 @@ export function createUserTurnTranscriptRecorder(
await waitForRuntimePersistence();
}
if (selfPersistencePromise) {
return await selfPersistencePromise;
const existingPromise = selfPersistencePromise;
const existingResult = await existingPromise;
if (existingResult || !options.retryIfUnpersisted) {
return existingResult;
}
// A guarded store write can lose a session-generation race without appending.
// Explicit retry callers may re-resolve the target, but concurrent ownership stays shared.
if (selfPersistencePromise !== existingPromise) {
return await selfPersistencePromise;
}
selfPersistencePromise = undefined;
}
selfPersistencePromise = (async () => {
const persistencePromise = (async () => {
const resolvedMessage = options.message ?? (await resolveMessageForPersistence());
if (!resolvedMessage) {
return undefined;
@@ -652,8 +663,13 @@ export function createUserTurnTranscriptRecorder(
}
return result;
})();
selfPersistencePromise = persistencePromise;
try {
return await selfPersistencePromise;
const result = await persistencePromise;
if (!result && options.retryIfUnpersisted && selfPersistencePromise === persistencePromise) {
selfPersistencePromise = undefined;
}
return result;
} catch (error) {
handlePersistenceError(error);
throw error;
@@ -697,6 +713,7 @@ export function createUserTurnTranscriptRecorder(
expectedSessionId: options?.expectedSessionId,
expectedSessionState: options?.expectedSessionState,
sessionLifecyclePatch: options?.sessionLifecyclePatch,
retryIfUnpersisted: options?.retryIfUnpersisted,
}),
persistBlocked: async (blockedMessage, options) => {
blocked = true;
@@ -144,6 +144,8 @@ export type UserTurnTranscriptRecorder = {
expectedSessionId?: string;
expectedSessionState?: SessionTranscriptTurnExpectedState;
sessionLifecyclePatch?: SessionTranscriptTurnLifecyclePatch;
/** Allow a later explicit persistence attempt when this attempt appends nothing. */
retryIfUnpersisted?: boolean;
}) => Promise<UserTurnTranscriptPersistResult | undefined>;
persistBlocked: (
message: PersistedUserTurnMessage,
+1 -1
View File
@@ -1,5 +1,5 @@
// Skill serialization helpers compact skill metadata and coordinate sync queue updates.
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
const skillsSyncQueue = new KeyedAsyncQueue();
+2 -2
View File
@@ -1,5 +1,3 @@
// Research autocapture helpers coordinate replay-safe capture and suggestion state.
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { resolveStorePath } from "../../config/sessions/paths.js";
import {
claimSessionSkillCaptureSignals,
@@ -11,6 +9,8 @@ import {
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { sha256Hex } from "../../infra/crypto-digest.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
// Research autocapture helpers coordinate replay-safe capture and suggestion state.
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { readWorkspaceSkillFile } from "../lifecycle/workspace-skill-write.js";
import { resolveSkillWorkshopConfig } from "../workshop/config.js";
import { stripProposalFrontmatterForSkill } from "../workshop/frontmatter.js";
+1 -1
View File
@@ -2,12 +2,12 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { resolveStateDir } from "../../config/paths.js";
import { sha256Hex } from "../../infra/crypto-digest.js";
import { type FileLockOptions, withFileLock } from "../../infra/file-lock.js";
import { root } from "../../infra/fs-safe.js";
import { tryReadJson } from "../../infra/json-files.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { normalizeSkillIndexName } from "../discovery/skill-index.js";
import {
assertInsideWorkspace,
+3 -3
View File
@@ -1,13 +1,13 @@
// OpenAI-compatible speech provider sends speech synthesis requests to OpenAI-style APIs.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { asFiniteNumber, asObject, trimToUndefined } from "../agents/provider-http-errors.js";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderBinaryResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { asFiniteNumber, asObject, trimToUndefined } from "../agents/provider-http-errors.js";
} from "../plugin-sdk/provider-http.js";
import type { SpeechProviderPlugin } from "../plugins/types.js";
import type {
SpeechDirectiveTokenParseContext,
+4 -4
View File
@@ -1,5 +1,8 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
// DashScope-compatible video provider adapts DashScope-style generation APIs.
import { readResponseWithLimit } from "../infra/http-body.js";
import { resolveGeneratedMediaMaxBytes } from "../media/configured-max-bytes.js";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
@@ -11,10 +14,7 @@ import {
resolveProviderOperationTimeoutMs,
waitProviderOperationPollInterval,
type ProviderOperationTimeoutMs,
} from "openclaw/plugin-sdk/provider-http";
// DashScope-compatible video provider adapts DashScope-style generation APIs.
import { readResponseWithLimit } from "../infra/http-body.js";
import { resolveGeneratedMediaMaxBytes } from "../media/configured-max-bytes.js";
} from "../plugin-sdk/provider-http.js";
import type {
GeneratedVideoAsset,
VideoGenerationProviderCapabilities,
+46
View File
@@ -260,6 +260,52 @@ docker_build_resource_exhausted_failure "$LOG_PATH"
}
});
it("detects compiler processes killed by the OOM killer", () => {
const workDir = tempDirs.make("openclaw-docker-build-killed-compiler-");
const logPath = join(workDir, "docker-build.log");
writeFileSync(logPath, "c++: fatal error: Killed signal terminated program cc1plus\n");
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
LOG_PATH=${shellQuote(logPath)}
source "$ROOT_DIR/scripts/lib/docker-build.sh"
docker_build_resource_exhausted_failure "$LOG_PATH"
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
});
it("retries Corepack connect timeouts without misreading Dockerfile comments as OOM", () => {
const workDir = tempDirs.make("openclaw-docker-build-connect-timeout-");
try {
const logPath = join(workDir, "docker-build.log");
writeFileSync(
logPath,
[
'# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).',
"ConnectTimeoutError: Connect Timeout Error (attempted addresses: 192.0.2.1:443)",
].join("\n"),
);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
LOG_PATH=${shellQuote(logPath)}
source "$ROOT_DIR/scripts/lib/docker-build.sh"
docker_build_transient_failure "$LOG_PATH"
if docker_build_resource_exhausted_failure "$LOG_PATH"; then
exit 3
fi
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("keeps shell-script Docker builds behind the helper", () => {
for (const path of CENTRALIZED_BUILD_SCRIPTS) {
const script = readFileSync(path, "utf8");
+32 -2
View File
@@ -1,17 +1,47 @@
// Live Docker Stage tests cover live docker stage script behavior.
import { readFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { addStagedPrivatePluginSdkExports } from "../../scripts/live-docker-stage-private-sdk-exports.mjs";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const stageScriptPath = path.join(repoRoot, "scripts/lib/live-docker-stage.sh");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("live Docker state staging", () => {
it("keeps repo-local generated artifacts out of the source copy", () => {
const script = readFileSync(stageScriptPath, "utf8");
expect(script).toContain("--exclude=.artifacts");
expect(script).toContain('node "$scripts_dir/live-docker-stage-private-sdk-exports.mjs"');
});
it("adds private SDK source exports only to the disposable source stage", () => {
const root = tempDirs.make("openclaw-live-stage-sdk-");
mkdirSync(path.join(root, "scripts", "lib"), { recursive: true });
mkdirSync(path.join(root, "src", "plugin-sdk"), { recursive: true });
writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ exports: { "./plugin-sdk/core": "./dist/plugin-sdk/core.js" } }),
);
writeFileSync(
path.join(root, "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"),
JSON.stringify(["keyed-async-queue"]),
);
writeFileSync(path.join(root, "src", "plugin-sdk", "keyed-async-queue.ts"), "export {};\n");
addStagedPrivatePluginSdkExports(root);
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
expect(packageJson.exports).toEqual({
"./plugin-sdk/core": "./dist/plugin-sdk/core.js",
"./plugin-sdk/keyed-async-queue": {
types: "./src/plugin-sdk/keyed-async-queue.ts",
default: "./src/plugin-sdk/keyed-async-queue.ts",
},
});
});
it("keeps host-only generated registry state out of the container copy", () => {
@@ -87,12 +87,18 @@ describe("scripts/test-live-codex-harness-docker.sh", () => {
);
});
it("forwards the live Codex bind provider override into Docker", () => {
it("forwards the live Codex bind controls into Docker", () => {
const script = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain(
'-e OPENCLAW_LIVE_CODEX_BIND_PROVIDER="${OPENCLAW_LIVE_CODEX_BIND_PROVIDER:-}"',
);
expect(script).toContain(
'-e OPENCLAW_LIVE_CODEX_BIND_REQUEST_TIMEOUT_MS="${OPENCLAW_LIVE_CODEX_BIND_REQUEST_TIMEOUT_MS:-}"',
);
expect(script).toContain(
'-e OPENCLAW_LIVE_CODEX_BIND_TIMEOUT_MS="${OPENCLAW_LIVE_CODEX_BIND_TIMEOUT_MS:-}"',
);
});
it("forwards bounded resume stress controls into Docker", () => {