diff --git a/Dockerfile b/Dockerfile index dfa62368c0e0..17d3dd49f142 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 8201999556dd..f65782c72b11 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index af947358fdb1..0bdcf0e8fee1 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -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 diff --git a/scripts/check-plugin-sdk-subpath-exports.mjs b/scripts/check-plugin-sdk-subpath-exports.mjs index e22ae5dd7734..1d6c1faec286 100644 --- a/scripts/check-plugin-sdk-subpath-exports.mjs +++ b/scripts/check-plugin-sdk-subpath-exports.mjs @@ -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/ must exist in the public package exports.", + "Rule: every referenced openclaw/plugin-sdk/ 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); diff --git a/scripts/lib/docker-build.sh b/scripts/lib/docker-build.sh index 0b321524d046..54e00be8eb2d 100644 --- a/scripts/lib/docker-build.sh +++ b/scripts/lib/docker-build.sh @@ -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() { diff --git a/scripts/lib/live-docker-stage.sh b/scripts/lib/live-docker-stage.sh index 1d6f44620542..6f96c652c1ec 100644 --- a/scripts/lib/live-docker-stage.sh +++ b/scripts/lib/live-docker-stage.sh @@ -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() { diff --git a/scripts/live-docker-stage-private-sdk-exports.d.mts b/scripts/live-docker-stage-private-sdk-exports.d.mts new file mode 100644 index 000000000000..756d9242a848 --- /dev/null +++ b/scripts/live-docker-stage-private-sdk-exports.d.mts @@ -0,0 +1 @@ +export function addStagedPrivatePluginSdkExports(repoRoot: string): void; diff --git a/scripts/live-docker-stage-private-sdk-exports.mjs b/scripts/live-docker-stage-private-sdk-exports.mjs new file mode 100644 index 000000000000..38e36debb8a4 --- /dev/null +++ b/scripts/live-docker-stage-private-sdk-exports.mjs @@ -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] ?? ".")); +} diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh index b8f5cfe4a8c8..a21e524341f2 100644 --- a/scripts/test-live-codex-harness-docker.sh +++ b/scripts/test-live-codex-harness-docker.sh @@ -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) diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 5e55a04bca8c..9a02a1038180 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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"], diff --git a/src/acp/control-plane/session-actor-queue.ts b/src/acp/control-plane/session-actor-queue.ts index bfc5552c22a5..2180786a818c 100644 --- a/src/acp/control-plane/session-actor-queue.ts +++ b/src/acp/control-plane/session-actor-queue.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 { diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 7831745f7fa4..33fb1c95d75f 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -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"; diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index ec3ad376d8d8..473b1587250e 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -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"; diff --git a/src/agents/models-config-state.test-support.ts b/src/agents/models-config-state.test-support.ts index bd6bbfce4486..788553331a59 100644 --- a/src/agents/models-config-state.test-support.ts +++ b/src/agents/models-config-state.test-support.ts @@ -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 { diff --git a/src/agents/models-config-state.ts b/src/agents/models-config-state.ts index f64708bbe560..c1483db89787 100644 --- a/src/agents/models-config-state.ts +++ b/src/agents/models-config-state.ts @@ -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"); diff --git a/src/agents/sessions/extensions/loader.ts b/src/agents/sessions/extensions/loader.ts index 7b317a6f436e..49ebba9d08f1 100644 --- a/src/agents/sessions/extensions/loader.ts +++ b/src/agents/sessions/extensions/loader.ts @@ -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, diff --git a/src/agents/sessions/tools/file-mutation-queue.ts b/src/agents/sessions/tools/file-mutation-queue.ts index afe2554af1df..ce3215132a35 100644 --- a/src/agents/sessions/tools/file-mutation-queue.ts +++ b/src/agents/sessions/tools/file-mutation-queue.ts @@ -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(); diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 1a5867704676..4e449b0610e7 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index b5f3a9792cc6..17e27e693c4c 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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; diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts index c4f8457932f6..6483430e1bf1 100644 --- a/src/auto-reply/reply/commands-diagnostics.ts +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -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" || diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 88cbb2807e38..393126a570df 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -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" || diff --git a/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts index d06b5d00fec9..b3c86f478c9b 100644 --- a/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts @@ -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, + "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( diff --git a/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts index 56f09211b1ec..12ad2f1d8b76 100644 --- a/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts @@ -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 } }); diff --git a/src/auto-reply/reply/dispatch-from-config.plugin-binding.ts b/src/auto-reply/reply/dispatch-from-config.plugin-binding.ts index ae8b221c1eaa..726cbe245c7d 100644 --- a/src/auto-reply/reply/dispatch-from-config.plugin-binding.ts +++ b/src/auto-reply/reply/dispatch-from-config.plugin-binding.ts @@ -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; } diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index f790cefb5cab..7c013f1f3978 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -134,7 +134,7 @@ const sessionStoreMocks = vi.hoisted(() => ({ currentEntry: undefined as Record | undefined, entriesBySessionKey: new Map>(), 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), diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 556750aae90d..714900dc2e1f 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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 => { - 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 => { 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 | 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" }); diff --git a/src/auto-reply/reply/get-reply.test-loader.ts b/src/auto-reply/reply/get-reply.test-loader.ts index 044383a4b966..03f980a21676 100644 --- a/src/auto-reply/reply/get-reply.test-loader.ts +++ b/src/auto-reply/reply/get-reply.test-loader.ts @@ -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"); diff --git a/src/auto-reply/reply/restart-recovery-claim.test.ts b/src/auto-reply/reply/restart-recovery-claim.test.ts new file mode 100644 index 000000000000..a4500c4ce727 --- /dev/null +++ b/src/auto-reply/reply/restart-recovery-claim.test.ts @@ -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(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", + }); + }); +}); diff --git a/src/auto-reply/reply/restart-recovery-claim.ts b/src/auto-reply/reply/restart-recovery-claim.ts index e51c1af0a850..b678c6f8e3b0 100644 --- a/src/auto-reply/reply/restart-recovery-claim.ts +++ b/src/auto-reply/reply/restart-recovery-claim.ts @@ -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"); } diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 56eb3e818389..09e907d68228 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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", () => { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a12bb893aca9..9c1ea0909399 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -197,6 +197,7 @@ type InitSessionStateAttemptContext = { agentId: string; conversationBindingContext: ReturnType; 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 { 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}`); } diff --git a/src/auto-reply/stage-sandbox-media.test-harness.ts b/src/auto-reply/stage-sandbox-media.test-harness.ts index d716bc9958b3..c69906fbad11 100644 --- a/src/auto-reply/stage-sandbox-media.test-harness.ts +++ b/src/auto-reply/stage-sandbox-media.test-harness.ts @@ -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( diff --git a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts index ed310231cd53..d8b67d5b48bd 100644 --- a/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts +++ b/src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts @@ -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, diff --git a/src/config/mutate.ts b/src/config/mutate.ts index 0b958ac9d93a..9decdcd28b2b 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -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"; diff --git a/src/config/sessions/transcript-append.test-support.ts b/src/config/sessions/transcript-append.test-support.ts index 99a1f7894931..11b7c8baef27 100644 --- a/src/config/sessions/transcript-append.test-support.ts +++ b/src/config/sessions/transcript-append.test-support.ts @@ -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"; diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index e5f8de676a3f..29c56d3e02d6 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -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"; diff --git a/src/cron/isolated-agent.test-harness.ts b/src/cron/isolated-agent.test-harness.ts index f1ee45e72403..f07792576f4a 100644 --- a/src/cron/isolated-agent.test-harness.ts +++ b/src/cron/isolated-agent.test-harness.ts @@ -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. */ diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 8186bda4cb05..6d6778179b1f 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -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"); diff --git a/src/gateway/gateway-codex-bind.live.test.ts b/src/gateway/gateway-codex-bind.live.test.ts index 012bc29d15b0..d695ef2cc72c 100644 --- a/src/gateway/gateway-codex-bind.live.test.ts +++ b/src/gateway/gateway-codex-bind.live.test.ts @@ -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 { - 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 { - 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 { 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> | undefined; let client: Awaited> | 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."); diff --git a/src/gateway/gateway-codex-harness.live-helpers.test.ts b/src/gateway/gateway-codex-harness.live-helpers.test.ts index 8854b8df303b..cd417b941fee 100644 --- a/src/gateway/gateway-codex-harness.live-helpers.test.ts +++ b/src/gateway/gateway-codex-harness.live-helpers.test.ts @@ -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"); }); diff --git a/src/gateway/gateway-codex-harness.live-helpers.ts b/src/gateway/gateway-codex-harness.live-helpers.ts index 0c98cd552d99..fa03b4dfc8fe 100644 --- a/src/gateway/gateway-codex-harness.live-helpers.ts +++ b/src/gateway/gateway-codex-harness.live-helpers.ts @@ -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. * diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index 547a70d1c90e..80248fbded69 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -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 { - 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`); } diff --git a/src/gateway/rate-limit-attempt-serialization.ts b/src/gateway/rate-limit-attempt-serialization.ts index 317f9f172640..ff2d705b6fa2 100644 --- a/src/gateway/rate-limit-attempt-serialization.ts +++ b/src/gateway/rate-limit-attempt-serialization.ts @@ -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(); diff --git a/src/gateway/server-methods/chat-send-nonagent-finalization.ts b/src/gateway/server-methods/chat-send-nonagent-finalization.ts index ef29285007cf..1e57003427c9 100644 --- a/src/gateway/server-methods/chat-send-nonagent-finalization.ts +++ b/src/gateway/server-methods/chat-send-nonagent-finalization.ts @@ -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 | 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, diff --git a/src/gateway/server-methods/chat-send-reply-dispatch.ts b/src/gateway/server-methods/chat-send-reply-dispatch.ts index c91374314f52..8e6d555a4212 100644 --- a/src/gateway/server-methods/chat-send-reply-dispatch.ts +++ b/src/gateway/server-methods/chat-send-reply-dispatch.ts @@ -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) { diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 2fcfcada9e70..ef6628b1c575 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -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"; diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index bf705419069c..da5244962cbf 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -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"; diff --git a/src/gateway/server-methods/system-agent.ts b/src/gateway/server-methods/system-agent.ts index 43c5625fb33d..6b1736ed3450 100644 --- a/src/gateway/server-methods/system-agent.ts +++ b/src/gateway/server-methods/system-agent.ts @@ -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"; diff --git a/src/image-generation/openai-compatible-image-provider.ts b/src/image-generation/openai-compatible-image-provider.ts index 45276f542ed3..87065b1d5558 100644 --- a/src/image-generation/openai-compatible-image-provider.ts +++ b/src/image-generation/openai-compatible-image-provider.ts @@ -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, diff --git a/src/llm/providers/stream-wrappers/moonshot-thinking.ts b/src/llm/providers/stream-wrappers/moonshot-thinking.ts index 11dca1ffa795..28eed1578874 100644 --- a/src/llm/providers/stream-wrappers/moonshot-thinking.ts +++ b/src/llm/providers/stream-wrappers/moonshot-thinking.ts @@ -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 { - const runtime = await llmRuntimeLoader.load(); - return runtime.streamSimple; + return streamSimple; } function normalizeMoonshotThinkingType(value: unknown): MoonshotThinkingType | undefined { diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 4a67f526a54e..97fbd23aafde 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -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: | { diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 0de0cf6e32af..9b5de89cac7d 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -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, diff --git a/src/plugins/contracts/tts-contract-suites.ts b/src/plugins/contracts/tts-contract-suites.ts index 5cd940f367ec..627abf218fb1 100644 --- a/src/plugins/contracts/tts-contract-suites.ts +++ b/src/plugins/contracts/tts-contract-suites.ts @@ -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, ); -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( diff --git a/src/plugins/conversation-binding.test-fixtures.ts b/src/plugins/conversation-binding.test-fixtures.ts index 3330f1771cfb..996712e4e298 100644 --- a/src/plugins/conversation-binding.test-fixtures.ts +++ b/src/plugins/conversation-binding.test-fixtures.ts @@ -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; type PluginBindingGlobalState = { fallbackNoticeBindingIds: Set; @@ -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(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(); +} diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 6bdf1e54e284..ebfba7b7da7c 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -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(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, }); } diff --git a/src/sessions/user-turn-transcript.test.ts b/src/sessions/user-turn-transcript.test.ts index c59d44a09dfd..e6b9c4dc9e0d 100644 --- a/src/sessions/user-turn-transcript.test.ts +++ b/src/sessions/user-turn-transcript.test.ts @@ -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 }); diff --git a/src/sessions/user-turn-transcript.ts b/src/sessions/user-turn-transcript.ts index 260081697353..370b87f291f5 100644 --- a/src/sessions/user-turn-transcript.ts +++ b/src/sessions/user-turn-transcript.ts @@ -567,6 +567,7 @@ export function createUserTurnTranscriptRecorder( expectedSessionId?: string; expectedSessionState?: SessionTranscriptTurnPersistOptions["expectedSessionState"]; sessionLifecyclePatch?: SessionTranscriptTurnPersistOptions["sessionLifecyclePatch"]; + retryIfUnpersisted?: boolean; }): Promise => { 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; diff --git a/src/sessions/user-turn-transcript.types.ts b/src/sessions/user-turn-transcript.types.ts index b61dab6e5d46..9380b024278c 100644 --- a/src/sessions/user-turn-transcript.types.ts +++ b/src/sessions/user-turn-transcript.types.ts @@ -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; persistBlocked: ( message: PersistedUserTurnMessage, diff --git a/src/skills/loading/serialize.ts b/src/skills/loading/serialize.ts index 7b88a0df2ef3..ccee53ee92e8 100644 --- a/src/skills/loading/serialize.ts +++ b/src/skills/loading/serialize.ts @@ -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(); diff --git a/src/skills/research/autocapture.ts b/src/skills/research/autocapture.ts index c489ace83dc5..12433520f10d 100644 --- a/src/skills/research/autocapture.ts +++ b/src/skills/research/autocapture.ts @@ -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"; diff --git a/src/skills/workshop/store.ts b/src/skills/workshop/store.ts index 29814678a328..0ee991c33bb1 100644 --- a/src/skills/workshop/store.ts +++ b/src/skills/workshop/store.ts @@ -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, diff --git a/src/tts/openai-compatible-speech-provider.ts b/src/tts/openai-compatible-speech-provider.ts index 2b3f3f1f4a08..3530c39d968d 100644 --- a/src/tts/openai-compatible-speech-provider.ts +++ b/src/tts/openai-compatible-speech-provider.ts @@ -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, diff --git a/src/video-generation/dashscope-compatible.ts b/src/video-generation/dashscope-compatible.ts index 63f1f8dca5fa..0565818a9850 100644 --- a/src/video-generation/dashscope-compatible.ts +++ b/src/video-generation/dashscope-compatible.ts @@ -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, diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index f731e80f6921..a28937ae5163 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -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"); diff --git a/test/scripts/live-docker-stage.test.ts b/test/scripts/live-docker-stage.test.ts index b6dda69d35ee..0beadc5f413e 100644 --- a/test/scripts/live-docker-stage.test.ts +++ b/test/scripts/live-docker-stage.test.ts @@ -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", () => { diff --git a/test/scripts/test-live-codex-harness-docker.test.ts b/test/scripts/test-live-codex-harness-docker.test.ts index f56f6101abda..5b33f3b1ffe8 100644 --- a/test/scripts/test-live-codex-harness-docker.test.ts +++ b/test/scripts/test-live-codex-harness-docker.test.ts @@ -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", () => {