mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
3abf8ce964
commit
2bb6b870aa
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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";
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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:
|
||||
| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user