diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index b65213ae67f..70bfbed2b97 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import OpenClawKit import OpenClawProtocol enum OpenClawConfigFile { @@ -403,11 +404,17 @@ enum OpenClawConfigFile { } private static func readConfigHealthState() -> [String: Any] { - self.configHealthState + let persisted = OpenClawSQLiteStateStore.readConfigHealthState() + if !persisted.isEmpty { + self.configHealthState = persisted + return persisted + } + return self.configHealthState } private static func writeConfigHealthState(_ root: [String: Any]) { self.configHealthState = root + try? OpenClawSQLiteStateStore.writeConfigHealthState(root) } private static func configHealthEntry(state: [String: Any], configPath: String) -> [String: Any] { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift index efe44525501..03e947d71e8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift @@ -40,6 +40,29 @@ enum DeviceIdentityPaths { return FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".openclaw", isDirectory: true) } + + static func legacyStateDirURL() -> URL { + #if DEBUG + if let testingStateDirURL { + return testingStateDirURL + } + #endif + + for key in self.stateDirEnv { + if let raw = getenv(key) { + let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return URL(fileURLWithPath: value, isDirectory: true) + } + } + } + + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + return appSupport.appendingPathComponent("OpenClaw", isDirectory: true) + } + + return self.stateDirURL() + } } public enum DeviceIdentityStore { @@ -77,7 +100,7 @@ public enum DeviceIdentityStore { } private static func legacyIdentityURL() -> URL { - DeviceIdentityPaths.stateDirURL() + DeviceIdentityPaths.legacyStateDirURL() .appendingPathComponent("identity", isDirectory: true) .appendingPathComponent("device.json", isDirectory: false) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawSQLiteStateStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawSQLiteStateStore.swift index 862254eda39..1f6806e8b10 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawSQLiteStateStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawSQLiteStateStore.swift @@ -315,6 +315,60 @@ public enum OpenClawSQLiteStateStore { } } + public static func readConfigHealthState() -> [String: Any] { + do { + let db = try self.openStateDatabase() + defer { sqlite3_close(db) } + let sql = """ + SELECT config_path, last_known_good_json, last_promoted_good_json, last_observed_suspicious_signature + FROM config_health_entries + ORDER BY config_path ASC + """ + var statement: OpaquePointer? + try self.prepare(db, sql, &statement) + defer { sqlite3_finalize(statement) } + var entries: [String: Any] = [:] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { break } + guard status == SQLITE_ROW, let configPath = self.columnString(statement, index: 0) else { + throw self.sqliteError(db, context: "SQLite config health read failed") + } + var entry: [String: Any] = [:] + if let lastKnownGood = self.columnJSONDictionary(statement, index: 1) { + entry["lastKnownGood"] = lastKnownGood + } + if let lastPromotedGood = self.columnJSONDictionary(statement, index: 2) { + entry["lastPromotedGood"] = lastPromotedGood + } + if let signature = self.columnString(statement, index: 3) { + entry["lastObservedSuspiciousSignature"] = signature + } + entries[configPath] = entry + } + return entries.isEmpty ? [:] : ["entries": entries] + } catch { + self.logger.warning("SQLite config health read failed: \(error.localizedDescription, privacy: .public)") + return [:] + } + } + + public static func writeConfigHealthState(_ state: [String: Any]) throws { + let entries = state["entries"] as? [String: Any] ?? [:] + let updatedAtMs = Int(Date().timeIntervalSince1970 * 1000) + try self.withWriteTransaction { db in + try self.exec(db, "DELETE FROM config_health_entries") + for (configPath, rawEntry) in entries { + guard let entry = rawEntry as? [String: Any] else { continue } + try self.insertConfigHealthEntry( + db, + configPath: configPath, + entry: entry, + updatedAtMs: updatedAtMs) + } + } + } + public static func readPortGuardianRecords() -> [OpenClawSQLitePortGuardianRecord] { do { let db = try self.openStateDatabase() @@ -447,6 +501,17 @@ public enum OpenClawSQLiteStateStore { try self.exec( db, "CREATE INDEX IF NOT EXISTS idx_macos_port_guardian_records_port ON macos_port_guardian_records(port, timestamp DESC)") + try self.exec( + db, + """ + CREATE TABLE IF NOT EXISTS config_health_entries ( + config_path TEXT NOT NULL PRIMARY KEY, + last_known_good_json TEXT, + last_promoted_good_json TEXT, + last_observed_suspicious_signature TEXT, + updated_at_ms INTEGER NOT NULL + ) + """) } private static func prepare(_ db: OpaquePointer?, _ sql: String, _ statement: inout OpaquePointer?) throws { @@ -508,6 +573,50 @@ public enum OpenClawSQLiteStateStore { return String(cString: UnsafeRawPointer(raw).assumingMemoryBound(to: CChar.self)) } + private static func columnJSONDictionary(_ statement: OpaquePointer?, index: Int32) -> [String: Any]? { + guard let raw = self.columnString(statement, index: index), + let data = raw.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return object + } + + private static func jsonString(_ value: Any?) -> String? { + guard let value, !(value is NSNull), JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func insertConfigHealthEntry( + _ db: OpaquePointer?, + configPath: String, + entry: [String: Any], + updatedAtMs: Int) throws + { + let sql = """ + INSERT INTO config_health_entries ( + config_path, last_known_good_json, last_promoted_good_json, + last_observed_suspicious_signature, updated_at_ms + ) + VALUES (?, ?, ?, ?, ?) + """ + var statement: OpaquePointer? + try self.prepare(db, sql, &statement) + defer { sqlite3_finalize(statement) } + self.bindText(statement, index: 1, value: configPath) + self.bindNullableText(statement, index: 2, value: self.jsonString(entry["lastKnownGood"])) + self.bindNullableText(statement, index: 3, value: self.jsonString(entry["lastPromotedGood"])) + self.bindNullableText( + statement, + index: 4, + value: entry["lastObservedSuspiciousSignature"] as? String) + sqlite3_bind_int64(statement, 5, Int64(updatedAtMs)) + guard sqlite3_step(statement) == SQLITE_DONE else { + throw self.sqliteError(db, context: "SQLite config health write failed") + } + } + private static func withWriteTransaction(_ body: (OpaquePointer?) throws -> Void) throws { let db = try self.openStateDatabase() defer { sqlite3_close(db) } diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 285ea337cbb..b0d3cfbb774 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -572,8 +572,10 @@ export async function runCodexAppServerAttempt( channelId: hookChannelId, }, }); - const hadSessionFile = await pathExists(activeSessionFile); - let historyMessages = (await readMirroredSessionHistoryMessages(activeSessionFile)) ?? []; + const hadTranscript = hasSqliteSessionTranscriptEvents({ + agentId: sessionAgentId, + sessionId: activeSessionId, + }); const hookContextWindowFields = { ...(params.contextWindowInfo?.tokens ? { contextTokenBudget: params.contextWindowInfo.tokens } @@ -587,6 +589,11 @@ export async function runCodexAppServerAttempt( ? { contextWindowReferenceTokens: params.contextWindowInfo.referenceTokens } : {}), }; + let historyMessages = + (await readMirroredSessionHistoryMessages({ + agentId: sessionAgentId, + sessionId: activeSessionId, + })) ?? []; const hookContext = { runId: params.runId, agentId: sessionAgentId, @@ -1630,10 +1637,22 @@ export async function runCodexAppServerAttempt( }, ); try { - const preRetrySessionFile = activeSessionFile; - await clearCodexAppServerBinding(preRetrySessionFile); - if (activeSessionFile !== preRetrySessionFile) { - await clearCodexAppServerBinding(activeSessionFile); + const preRetrySessionId = activeSessionId; + const compactedForRetry = await forceContextEngineCompactionForCodexOverflow( + turnStartError, + { + threadId: thread.threadId, + }, + ); + await clearCodexAppServerBinding({ + sessionKey: sandboxSessionKey, + sessionId: preRetrySessionId, + }); + if (activeSessionId !== preRetrySessionId) { + await clearCodexAppServerBinding({ + sessionKey: sandboxSessionKey, + sessionId: activeSessionId, + }); } thread = await restartContextEngineCodexThread(); emitCodexAppServerEvent(params, { diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 3b81f8fe609..3bce8fe604c 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -33,6 +33,12 @@ export type CodexAppServerAuthProfileLookup = { config?: ProviderAuthAliasConfig; }; +export type CodexAppServerContextEngineBinding = { + schemaVersion: 1; + engineId: string; + policyFingerprint: string; +}; + export type CodexAppServerThreadBinding = { schemaVersion: 1; threadId: string; @@ -148,6 +154,12 @@ function normalizeCodexAppServerBinding( typeof parsed.dynamicToolsFingerprint === "string" ? parsed.dynamicToolsFingerprint : undefined, + userMcpServersFingerprint: + typeof parsed.userMcpServersFingerprint === "string" + ? parsed.userMcpServersFingerprint + : undefined, + mcpServersFingerprint: + typeof parsed.mcpServersFingerprint === "string" ? parsed.mcpServersFingerprint : undefined, pluginAppsFingerprint: typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined, pluginAppsInputFingerprint: @@ -155,6 +167,7 @@ function normalizeCodexAppServerBinding( ? parsed.pluginAppsInputFingerprint : undefined, pluginAppPolicyContext: readPluginAppPolicyContext(parsed.pluginAppPolicyContext), + contextEngine: readContextEngineBinding(parsed.contextEngine), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(), updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(), }; diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index dc9436efb98..0c7bdd03bf8 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -49,6 +49,7 @@ import { type CodexAppServerContextEngineBinding, type CodexAppServerContextEngineProjectionBinding, type CodexAppServerBindingIdentity, + type CodexAppServerContextEngineBinding, type CodexAppServerThreadBinding, } from "./session-binding.js"; @@ -106,10 +107,9 @@ export const CODEX_CODE_MODE_DISABLED_THREAD_CONFIG: JsonObject = { "features.code_mode_only": false, }; -const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = { +export const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = { project_doc_max_bytes: 0, }; - function resolveCodexAppServerBindingIdentity( params: EmbeddedRunAttemptParams, ): CodexAppServerBindingIdentity { diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index b3419a016f4..ec048bfe365 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -474,6 +474,19 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ? [{ path: attachmentPath, contentType: a.mime_type ?? undefined }] : []; }); + const stagedMediaAttachments = await stageIMessageAttachments(validAttachments, { + maxBytes: mediaMaxBytes, + allowedRoots: effectiveAttachmentRoots, + deps: { + logVerbose, + }, + }); + const mediaAttachments = + stagedMediaAttachments.length > 0 ? stagedMediaAttachments : rawMediaAttachments; + const mediaPath = mediaAttachments[0]?.path; + const mediaType = mediaAttachments[0]?.contentType; + const mediaPaths = mediaAttachments.map((entry) => entry.path); + const mediaTypes = mediaAttachments.map((entry) => entry.contentType ?? ""); const placeholderMediaType = rawMediaAttachments[0]?.contentType; const kind = kindFromMime(placeholderMediaType ?? undefined); const placeholder = kind diff --git a/extensions/telegram/src/bot-deps.ts b/extensions/telegram/src/bot-deps.ts index 3dc36f2e35b..c17510951ae 100644 --- a/extensions/telegram/src/bot-deps.ts +++ b/extensions/telegram/src/bot-deps.ts @@ -3,9 +3,9 @@ import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inb import { createChannelMessageReplyPipeline, deliverInboundReplyWithMessageSendContext, -} from "openclaw/plugin-sdk/channel-outbound"; -import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; +} from "openclaw/plugin-sdk/channel-message"; import { + readChannelAllowFromStore, recordInboundSession, upsertChannelPairingRequest, } from "openclaw/plugin-sdk/conversation-runtime"; @@ -19,7 +19,6 @@ import { listSessionEntries, patchSessionEntry, readSessionUpdatedAt, - resolveStorePath, } from "openclaw/plugin-sdk/session-store-runtime"; import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; @@ -38,7 +37,6 @@ export type TelegramBotDeps = { getSessionEntry: typeof getSessionEntry; listSessionEntries: typeof listSessionEntries; patchSessionEntry: typeof patchSessionEntry; - resolveStorePath: typeof resolveStorePath; readSessionUpdatedAt?: typeof readSessionUpdatedAt; recordInboundSession?: typeof recordInboundSession; recordChannelActivity?: typeof recordChannelActivity; @@ -78,9 +76,6 @@ export const defaultTelegramBotDeps: TelegramBotDeps = { get patchSessionEntry() { return patchSessionEntry; }, - get resolveStorePath() { - return resolveStorePath; - }, get readSessionUpdatedAt() { return readSessionUpdatedAt; }, diff --git a/extensions/telegram/src/bot-message-context.session.runtime.ts b/extensions/telegram/src/bot-message-context.session.runtime.ts index 77003a237db..2d4b2683bcc 100644 --- a/extensions/telegram/src/bot-message-context.session.runtime.ts +++ b/extensions/telegram/src/bot-message-context.session.runtime.ts @@ -1,5 +1,5 @@ export { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound"; -export { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +export { readSessionUpdatedAt } from "openclaw/plugin-sdk/session-store-runtime"; export { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; export { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; export { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index 9f8ccc9fd47..d92d53fed9d 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -94,7 +94,6 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep resolvePinnedMainDmOwnerFromAllowlist: telegramDeps.resolvePinnedMainDmOwnerFromAllowlist, } : {}), - resolveStorePath: telegramDeps.resolveStorePath, }; const contextRuntime = telegramDeps.recordChannelActivity ? { recordChannelActivity: telegramDeps.recordChannelActivity } diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index f325d16faea..cfff2e853e7 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -392,7 +392,6 @@ export const telegramBotDepsForTest: TelegramBotDeps = { getSessionEntry: getSessionEntryMock, listSessionEntries: listSessionEntriesMock, patchSessionEntry: patchSessionEntryMock, - resolveStorePath: () => "", readSessionUpdatedAt: () => undefined, recordInboundSession: recordInboundSessionMock as TelegramBotDeps["recordInboundSession"], recordChannelActivity: vi.fn() as TelegramBotDeps["recordChannelActivity"], diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0f615f3526a..bc93c6bd5da 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -82,6 +82,7 @@ import { formatDuplicateTelegramTokenReason, telegramConfigAdapter, } from "./shared.js"; +import { withTelegramStartupProbeSlot } from "./startup-probe-limiter.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; import { diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index fea9098c0bc..6c36cf132a2 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -15,6 +15,7 @@ import { } from "./path-policy.js"; import { assertSandboxPath } from "./sandbox-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; +import { relativePathEscapesContainerRoot as relativePathEscapesRoot } from "./sandbox/path-utils.js"; const BEGIN_PATCH_MARKER = "*** Begin Patch"; const END_PATCH_MARKER = "*** End Patch"; diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 08cdd0e12d2..29fdde14d17 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -16,6 +16,7 @@ import type { } from "../../sessions/user-turn-transcript.js"; import type { BootstrapContextMode } from "../bootstrap-files.js"; import type { ResolvedCliBackend } from "../cli-backends.js"; +import type { ContextWindowInfo } from "../context-window-guard.js"; import type { ImageContent } from "../pi-ai-contract.js"; import type { CurrentInboundPromptContext, diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index fa3d97aef3d..3f0715e0a9d 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -88,6 +88,7 @@ import { shouldPreferExplicitConfigApiKeyAuth, } from "../model-auth.js"; import { ensureOpenClawModelCatalog } from "../models-config.js"; +import { resolveContextConfigProviderForRuntime } from "../openai-codex-routing.js"; import { OPENAI_CODEX_PROVIDER_ID, listOpenAIAuthProfileProvidersForAgentRuntime, diff --git a/src/agents/embedded-agent-runner/run/payloads.ts b/src/agents/embedded-agent-runner/run/payloads.ts index 98358580913..f71693e491d 100644 --- a/src/agents/embedded-agent-runner/run/payloads.ts +++ b/src/agents/embedded-agent-runner/run/payloads.ts @@ -1,4 +1,4 @@ -import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js"; import { createHeartbeatToolResponsePayload, type HeartbeatToolResponse, diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index e3e274ba9a9..761077cff91 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -15,7 +15,10 @@ import type { AuthProfileStore } from "../../auth-profiles/types.js"; import type { ModelRegistry } from "../../model-registry-contract.js"; import type { Api, AssistantMessage, Model } from "../../pi-ai-contract.js"; import type { AuthStorage } from "../../pi-coding-agent-contract.js"; -import type { MessagingToolSend } from "../../pi-embedded-messaging.types.js"; +import type { + MessagingToolSend, + MessagingToolSourceReplyPayload, +} from "../../pi-embedded-messaging.types.js"; import type { ToolOutcomeObserver } from "../../pi-tools.before-tool-call.js"; import type { AgentRunTimeoutPhase } from "../../run-timeout-attribution.js"; import type { AgentRuntimePlan } from "../../runtime-plan/types.js"; diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts index 948a45f93db..2d8791125f0 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -1,4 +1,3 @@ -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { TSchema } from "typebox"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 54d408693e8..36187ea6ac6 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -773,7 +773,7 @@ export function installSessionToolResultGuard( suppressNextUserMessagePersistence = false; return undefined; } - const result = originalAppend(finalMessage as never); + const result = appendMessageAndCacheTranscriptSeq(finalMessage); if (opts?.sessionId || opts?.sessionKey) { emitSessionTranscriptUpdate({ @@ -781,8 +781,8 @@ export function installSessionToolResultGuard( ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}), sessionKey: opts?.sessionKey, message: finalMessage, - messageId: typeof result === "string" ? result : undefined, - ...(messageSeq !== undefined ? { messageSeq } : {}), + messageId: result.entryId, + ...(result.messageSeq !== undefined ? { messageSeq: result.messageSeq } : {}), }); } @@ -801,7 +801,7 @@ export function installSessionToolResultGuard( ); } - return result; + return result.entryId; }; // Monkey-patch appendMessage with our guarded version. diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index d381ff9a287..75fe5a55c82 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -20,6 +20,7 @@ import { resolveDefaultModelForAgent, resolveModelRefFromString, } from "./model-selection.js"; +import { isOpenAIProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-codex-routing.js"; import { completeSimple, type Api, diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 02e0e3f74e2..2ea6dac62fb 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -67,6 +67,7 @@ import type { SpawnSubagentMode } from "./subagent-spawn.types.js"; const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000; const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000; type SubagentAnnounceDeliveryDeps = { + callGateway: typeof callGateway; dispatchGatewayMethodInProcess: typeof dispatchGatewayMethodInProcess; getRuntimeConfig: typeof getRuntimeConfig; getRequesterSessionActivity: (requesterSessionKey: string) => { @@ -82,6 +83,7 @@ type SubagentAnnounceDeliveryDeps = { }; const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = { + callGateway, dispatchGatewayMethodInProcess, getRuntimeConfig, getRequesterSessionActivity: (requesterSessionKey: string) => { @@ -112,24 +114,6 @@ async function resolveQueueEmbeddedAgentMessageOutcome( ); } -async function runAnnounceAgentCall(params: { - agentParams: Record; - expectFinal?: boolean; - timeoutMs?: number; -}): Promise { - return await subagentAnnounceDeliveryDeps.dispatchGatewayMethodInProcess( - "agent", - params.agentParams, - { - expectFinal: params.expectFinal, - forceSyntheticClient: shouldPreserveUserFacingSessionStateForInputProvenance( - params.agentParams.inputProvenance, - ), - timeoutMs: params.timeoutMs, - }, - ); -} - function formatQueueWakeFailureError( fallback: string, outcome: EmbeddedAgentQueueMessageOutcome, diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 06c1485c6f8..39e850fe839 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -7,6 +7,7 @@ import { resolveCliRuntimeModelBackendBinding } from "../../agents/cli-backends. import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import { normalizeProviderId, type ModelAliasIndex } from "../../agents/model-selection.js"; +import { resolveContextConfigProviderForRuntime } from "../../agents/openai-codex-routing.js"; import { getSessionEntry, mergeSessionEntry, upsertSessionEntry } from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 9c1b1af795e..6b75bfa779e 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -29,6 +29,15 @@ function getOfficialCatalogFileCache(): Map { + const globalKey = "__openclawBundledPackageCatalogCache"; + const globals = globalThis as typeof globalThis & { + [globalKey]?: Map; + }; + globals[globalKey] ??= new Map(); + return globals[globalKey]; +} + function listPackageRoots(): string[] { return uniqueStrings( [ @@ -43,6 +52,7 @@ function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] { if (!pluginsDir) { return []; } + const bundledPackageCatalogCache = getBundledPackageCatalogCache(); const cached = bundledPackageCatalogCache.get(pluginsDir); if (cached !== undefined) { return cached ?? []; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index e5694704b50..eab6faf226d 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -2248,7 +2248,6 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis }, }; - await tryWriteCompletionCache(root, Boolean(opts.json)); if (opts.json) { defaultRuntime.writeJson(result); } else if (result.status === "ok") { diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 872ab2a6aa8..cf1c8434447 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -11,8 +11,8 @@ import { info } from "../globals.js"; import { writeTextAtomic } from "../infra/json-files.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; -import { classifySessionKind, type SessionKind } from "../sessions/classify-session-kind.js"; -import { isAcpSessionKey } from "../sessions/session-key-utils.js"; +import type { SessionKind } from "../sessions/classify-session-kind.js"; +import { isAcpSessionKey, isCronSessionKey } from "../sessions/session-key-utils.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalLowercaseString, @@ -422,6 +422,7 @@ export async function sessionsCommand( .map(({ sessionKey: key, entry }) => { const row = toSessionDisplayRow(key, entry); const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId; + const acpRuntime = entry?.acp != null; const modelRef = resolveSessionDisplayModelRef(cfg, { ...row, agentId }); const agentRuntime = resolveModelAgentRuntimeMetadata({ cfg, diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index 6b9ec823f0e..ff664a4834e 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -6,7 +6,7 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.js"; -import { classifySessionKind } from "../sessions/classify-session-kind.js"; +import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -220,7 +220,7 @@ function resolveContextTokensForModel(params: { export const statusSummaryRuntime = { resolveContextTokensForModel, - classifySessionKey: classifySessionKind, + classifySessionKey, resolveSessionModelRef, resolveSessionRuntimeLabel, resolveConfiguredStatusModelRef, diff --git a/src/config/mutate.ts b/src/config/mutate.ts index d6f7b5e7493..e1c32630934 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -4,7 +4,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { formatErrorMessage } from "../infra/errors.js"; -import { withFileLock } from "../infra/file-lock.js"; import { replaceFileAtomic } from "../infra/replace-file.js"; import { isPathInside } from "../security/scan-paths.js"; import { isRecord } from "../utils.js"; @@ -40,17 +39,6 @@ import { validateConfigObjectWithPlugins } from "./validation.js"; export type ConfigMutationBase = "runtime" | "source"; -const CONFIG_MUTATION_LOCK_OPTIONS = { - retries: { - retries: 80, - factor: 1.2, - minTimeout: 25, - maxTimeout: 250, - randomize: true, - }, - stale: 30_000, -} as const; - const DEFAULT_CONFIG_MUTATION_RETRY_ATTEMPTS = 5; const activeConfigMutationLocks = new AsyncLocalStorage>(); const configMutationQueueTails = new Map>(); @@ -173,10 +161,7 @@ async function withConfigMutationLock( try { const nextActiveLocks = new Set(activeLocks ?? []); nextActiveLocks.add(configPath); - return await activeConfigMutationLocks.run( - nextActiveLocks, - async () => await withFileLock(configPath, CONFIG_MUTATION_LOCK_OPTIONS, fn), - ); + return await activeConfigMutationLocks.run(nextActiveLocks, fn); } finally { releaseQueueSlot(); if (configMutationQueueTails.get(configPath) === currentTail) { diff --git a/src/config/sessions/store-maintenance-preserve.ts b/src/config/sessions/store-maintenance-preserve.ts index dd59ba5e41b..02df57247ef 100644 --- a/src/config/sessions/store-maintenance-preserve.ts +++ b/src/config/sessions/store-maintenance-preserve.ts @@ -1,4 +1,4 @@ -import { normalizeStoreSessionKey } from "./store-entry.js"; +import { normalizeSessionRowKey } from "./store-entry.js"; export type SessionMaintenancePreserveKeysProvider = () => Iterable | undefined; @@ -14,10 +14,10 @@ export function registerSessionMaintenancePreserveKeysProvider( } function addSessionMaintenancePreserveKey(keys: Set, value: string | undefined): void { - // Match how store keys are normalized in `normalizeStoreSessionKey` + // Match how store keys are normalized in `normalizeSessionRowKey` // (trim + lowercase) so providers can register session keys in any // case without missing matches during maintenance lookups. - const normalized = normalizeStoreSessionKey(value ?? ""); + const normalized = normalizeSessionRowKey(value ?? ""); if (normalized) { keys.add(normalized); } diff --git a/src/config/sessions/transcript-store.sqlite.test.ts b/src/config/sessions/transcript-store.sqlite.test.ts index 09fd23a5e2e..86ee6b0514e 100644 --- a/src/config/sessions/transcript-store.sqlite.test.ts +++ b/src/config/sessions/transcript-store.sqlite.test.ts @@ -76,6 +76,34 @@ describe("SQLite session transcript store", () => { ]); }); + it("reads transcript events from an explicit agent database path", () => { + const stateDir = createTempDir(); + const customPath = path.join(stateDir, "custom-agent.sqlite"); + const options = { + path: customPath, + env: { OPENCLAW_STATE_DIR: stateDir }, + agentId: "main", + sessionId: "session-1", + }; + + appendSqliteSessionTranscriptEvent({ + ...options, + event: { type: "session", id: "session-1" }, + now: () => 100, + }); + + expect(loadSqliteSessionTranscriptEvents(options)).toEqual([ + { seq: 0, createdAt: 100, event: { type: "session", id: "session-1" } }, + ]); + expect( + loadSqliteSessionTranscriptEvents({ + env: { OPENCLAW_STATE_DIR: stateDir }, + agentId: "main", + sessionId: "session-1", + }), + ).toEqual([]); + }); + it("dedupes message appends by SQLite idempotency identity", () => { const stateDir = createTempDir(); const options = { diff --git a/src/config/sessions/transcript-store.sqlite.ts b/src/config/sessions/transcript-store.sqlite.ts index f5949761461..8aa81b1ba0b 100644 --- a/src/config/sessions/transcript-store.sqlite.ts +++ b/src/config/sessions/transcript-store.sqlite.ts @@ -107,7 +107,7 @@ function getAgentTranscriptKysely(db: import("node:sqlite").DatabaseSync) { function openTranscriptAgentDatabase( options: SqliteSessionTranscriptStoreOptions, ): OpenClawAgentDatabase { - return openOpenClawAgentDatabase({ env: options.env, agentId: options.agentId }); + return openOpenClawAgentDatabase(options); } function readNextTranscriptSeq(database: OpenClawAgentDatabase, sessionId: string): number { diff --git a/src/gateway/server-methods/agents-config-mutations.ts b/src/gateway/server-methods/agents-config-mutations.ts index 0340bf61ab0..fbd7aa15ade 100644 --- a/src/gateway/server-methods/agents-config-mutations.ts +++ b/src/gateway/server-methods/agents-config-mutations.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { applyAgentConfig, @@ -6,7 +7,6 @@ import { pruneAgentConfig, } from "../../commands/agents.config.js"; import { mutateConfigFileWithRetry } from "../../config/config.js"; -import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions.js"; import type { IdentityConfig } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -99,7 +99,7 @@ export async function deleteAgentConfigEntry(params: { agentId: string }): Promi } const workspaceDir = resolveAgentWorkspaceDir(draft, params.agentId); const agentDir = resolveAgentDir(draft, params.agentId); - const sessionsDir = resolveSessionTranscriptsDirForAgent(params.agentId); + const sessionsDir = path.join(agentDir, "sessions"); const result = pruneAgentConfig(draft, params.agentId); Object.assign(draft, result.config); return { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index a4029f379de..33b1faf149b 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -20,13 +20,7 @@ import { ensureAgentWorkspace, isWorkspaceSetupCompleted, } from "../../agents/workspace.js"; -import { - applyAgentConfig, - findAgentEntryIndex, - listAgentEntries, - pruneAgentConfig, -} from "../../commands/agents.config.js"; -import { replaceConfigFile } from "../../config/config.js"; +import { applyAgentConfig } from "../../commands/agents.config.js"; import { purgeAgentSessionRows } from "../../config/sessions.js"; import type { IdentityConfig } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -704,11 +698,12 @@ export const agentsHandlers: GatewayRequestHandlers = { const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const agentDir = resolveAgentDir(cfg, agentId); - const result = pruneAgentConfig(cfg, agentId); - await replaceConfigFile({ - nextConfig: result.config, - afterWrite: { mode: "auto" }, - }); + const committed = await deleteAgentConfigEntry({ agentId }); + const deleteResult = committed.result; + if (!deleteResult) { + respondAgentNotFound(respond, agentId); + return; + } // Purge SQLite session rows so orphaned sessions cannot be targeted (#65524). await purgeAgentSessionRows(cfg, agentId); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 07757dc3850..faaa691cf12 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -455,6 +455,55 @@ function ensureSessionTranscriptScope(params: { } } +function isAgentMainSessionKey(cfg: OpenClawConfig, key: string): boolean { + const agentId = resolveAgentIdFromSessionKey(key) ?? resolveDefaultAgentId(cfg); + return key === resolveAgentMainSessionKey({ cfg, agentId }); +} + +async function createAgentMainSessionForSend(params: { + cfg: OpenClawConfig; + canonicalKey: string; + context: GatewayRequestContext; +}): Promise< + | { ok: true; canonicalKey: string; entry: SessionEntry } + | { ok: false; error: ReturnType } +> { + const target = resolveGatewaySessionDatabaseTarget({ + cfg: params.cfg, + key: params.canonicalKey, + }); + const store = loadAgentSessionRows(target.agentId); + const patched = await applySessionsPatchToStore({ + cfg: params.cfg, + store, + storeKey: target.canonicalKey, + patch: { key: target.canonicalKey }, + loadGatewayModelCatalog: params.context.loadGatewayModelCatalog, + }); + if (!patched.ok) { + return { ok: false, error: patched.error }; + } + upsertSessionEntry({ + agentId: target.agentId, + sessionKey: target.canonicalKey, + entry: patched.entry, + }); + const ensured = ensureSessionTranscriptScope({ + agentId: target.agentId, + sessionId: patched.entry.sessionId, + }); + if (!ensured.ok) { + return { + ok: false, + error: errorShape( + ErrorCodes.UNAVAILABLE, + `failed to create session transcript: ${ensured.error}`, + ), + }; + } + return { ok: true, canonicalKey: target.canonicalKey, entry: patched.entry }; +} + function resolveAbortSessionKey(params: { context: Pick; requestedKey: string; @@ -654,7 +703,10 @@ async function handleSessionSend(params: { if (!key) { return; } - const { cfg, entry, canonicalKey } = loadSessionEntry(key); + const loadedSession = loadSessionEntry(key); + const cfg = loadedSession.cfg; + let entry = loadedSession.entry; + let canonicalKey = loadedSession.canonicalKey; // Reject sends/steers targeting sessions whose owning agent was deleted (#65524). const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, canonicalKey); if (deletedAgentId !== null) { @@ -670,11 +722,9 @@ async function handleSessionSend(params: { } if (!entry?.sessionId && !params.interruptIfActive && isAgentMainSessionKey(cfg, canonicalKey)) { const created = await createAgentMainSessionForSend({ - req: params.req, + cfg, canonicalKey, context: params.context, - client: params.client, - isWebchatConnect: params.isWebchatConnect, }); if (!created.ok) { params.respond(false, undefined, created.error); @@ -682,7 +732,6 @@ async function handleSessionSend(params: { } entry = created.entry; canonicalKey = created.canonicalKey; - storePath = created.storePath; } if (!entry?.sessionId) { params.respond( diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 7425e5eb669..adf88e3e1d2 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -129,9 +129,11 @@ async function handleTranscriptUpdateBroadcast( } const { entry } = loadSessionEntry(sessionKey); const agentId = resolveAgentIdFromSessionKey(sessionKey); - const messageSeq = entry?.sessionId - ? await readSessionMessageCountAsync({ agentId, sessionId: entry.sessionId }) - : undefined; + const messageSeq = + asPositiveSafeInteger(update.messageSeq) ?? + (entry?.sessionId && agentId + ? await readSessionMessageCountAsync({ agentId, sessionId: entry.sessionId }) + : undefined); const sessionSnapshot = buildGatewaySessionSnapshot({ sessionRow: loadGatewaySessionRow(sessionKey, { transcriptUsageMaxBytes: 64 * 1024 }), includeSession: true, diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index b87be18f72e..9825f054260 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1038,11 +1038,11 @@ export function registerControlUiAndPairingSuite(): void { } }); - test("qr setup code returns node token plus bounded operator handoff", async () => { + test("requires approval before qr setup code returns a durable node token", async () => { const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { getPairedDevice, listDevicePairing, verifyDeviceToken } = + const { approveDevicePairing, getPairedDevice, listDevicePairing, verifyDeviceToken } = await import("../infra/device-pairing.js"); const { server, port, prevToken } = await startControlUiServer("secret"); @@ -1068,8 +1068,47 @@ export function registerControlUiAndPairingSuite(): void { client, deviceIdentityKey: identityKey, }); - expect(initial.ok).toBe(true); - const approvedPayload = initial.payload as + expect(initial.ok).toBe(false); + expect(initial.error?.message ?? "").toContain("pairing required"); + const initialDetails = initial.error?.details as + | { + code?: string; + pauseReconnect?: boolean; + recommendedNextStep?: string; + retryable?: boolean; + } + | undefined; + expect(initialDetails?.code).toBe(ConnectErrorDetailCodes.PAIRING_REQUIRED); + expect(initialDetails?.recommendedNextStep).toBe("wait_then_retry"); + expect(initialDetails?.retryable).toBe(true); + expect(initialDetails?.pauseReconnect).toBe(false); + + const pendingAfterInitial = await listDevicePairing(); + const pendingForDevice = pendingAfterInitial.pending.filter( + (entry) => entry.deviceId === identity.deviceId, + ); + expect(pendingForDevice).toHaveLength(1); + expect(pendingForDevice[0]?.role).toBe("node"); + expect(pendingForDevice[0]?.roles).toEqual(["node"]); + expect(await getPairedDevice(identity.deviceId)).toBeNull(); + expect( + await approveDevicePairing(pendingForDevice[0]?.requestId ?? "", { + callerScopes: ["operator.pairing"], + }), + ).toMatchObject({ status: "approved" }); + wsBootstrap.close(); + + const wsApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const approvedConnect = await connectReq(wsApproved, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityKey: identityKey, + }); + expect(approvedConnect.ok).toBe(true); + const approvedPayload = approvedConnect.payload as | { type?: string; auth?: { @@ -1091,50 +1130,26 @@ export function registerControlUiAndPairingSuite(): void { } expect(approvedPayload?.auth?.role).toBe("node"); expect(approvedPayload?.auth?.scopes ?? []).toEqual([]); - const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find( - (entry) => entry.role === "operator", - ); - const issuedOperatorToken = operatorHandoff?.deviceToken; - if (!issuedOperatorToken) { - throw new Error("expected handed-off operator device token"); - } - expect(operatorHandoff?.scopes).toEqual([ - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expect(operatorHandoff?.scopes).not.toContain("operator.admin"); - expect(operatorHandoff?.scopes).not.toContain("operator.pairing"); - - const pendingAfterInitial = await listDevicePairing(); - const pendingForDevice = pendingAfterInitial.pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pendingForDevice).toEqual([]); - wsBootstrap.close(); + expect(approvedPayload?.auth?.deviceTokens ?? []).toEqual([]); const afterBootstrap = await listDevicePairing(); expect( afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId), ).toEqual([]); const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(["node", "operator"]); - expect(paired?.approvedScopes).toEqual([ - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); + expect(paired?.roles).toEqual(["node"]); + expect(paired?.approvedScopes).toEqual([]); expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); - expect(paired?.tokens?.node?.scopes).toEqual([]); - expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken); - expect(paired?.tokens?.operator?.scopes).toEqual([ - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); + expect(paired?.tokens?.operator).toBeUndefined(); + + await new Promise((resolve) => { + if (wsApproved.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + wsApproved.once("close", () => resolve()); + wsApproved.close(); + }); const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const replay = await connectReq(wsReplay, { @@ -1184,7 +1199,7 @@ export function registerControlUiAndPairingSuite(): void { await expect( verifyDeviceToken({ deviceId: identity.deviceId, - token: issuedOperatorToken, + token: issuedDeviceToken, role: "operator", scopes: [ "operator.approvals", @@ -1193,123 +1208,18 @@ export function registerControlUiAndPairingSuite(): void { "operator.write", ], }), - ).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: issuedOperatorToken, - role: "operator", - scopes: ["operator.admin"], - }), - ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); - await expect( - verifyDeviceToken({ - deviceId: identity.deviceId, - token: issuedOperatorToken, - role: "operator", - scopes: ["operator.pairing"], - }), - ).resolves.toEqual({ ok: false, reason: "scope-mismatch" }); + ).resolves.toEqual({ ok: false, reason: "token-missing" }); } finally { await server.close(); restoreGatewayToken(prevToken); } }); - test("qr bootstrap retry keeps bounded operator handoff after paired approval", async () => { - const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = - await import("../infra/device-bootstrap.js"); - const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveBootstrapDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); - const { PAIRING_SETUP_BOOTSTRAP_PROFILE } = - await import("../shared/device-bootstrap-profile.js"); - const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity } = await createOperatorIdentityFixture( - "openclaw-bootstrap-node-retry-", - ); - const client = { - id: "openclaw-ios", - version: "2026.3.30", - platform: "iOS 26.3.1", - mode: "node", - deviceFamily: "iPhone", - }; - - try { - const issued = await issueDeviceBootstrapToken(); - const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); - const pending = await requestDevicePairing({ - deviceId: identity.deviceId, - publicKey, - role: "node", - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], - clientId: client.id, - clientMode: client.mode, - displayName: client.id, - platform: client.platform, - deviceFamily: client.deviceFamily, - silent: true, - }); - await approveBootstrapDevicePairing( - pending.request.requestId, - PAIRING_SETUP_BOOTSTRAP_PROFILE, - ); - - const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const retry = await connectReq(wsRetry, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(retry.ok).toBe(true); - const payload = retry.payload as - | { - auth?: { - deviceToken?: string; - deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>; - }; - } - | undefined; - expect(payload?.auth?.deviceToken).toBeTruthy(); - const operatorHandoff = payload?.auth?.deviceTokens?.find( - (entry) => entry.role === "operator", - ); - expect(operatorHandoff?.deviceToken).toBeTruthy(); - expect(operatorHandoff?.scopes).toEqual([ - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ]); - expect(operatorHandoff?.scopes).not.toContain("operator.admin"); - expect(operatorHandoff?.scopes).not.toContain("operator.pairing"); - wsRetry.close(); - - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: identity.deviceId, - publicKey, - role: "node", - scopes: [], - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("rejected non-baseline bootstrap request cannot recreate pending node pairing", async () => { + test("rejected qr setup code cannot recreate pending node pairing", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js"); const { server, port, prevToken } = await startControlUiServer("secret"); - const { identityPath, identity } = await createOperatorIdentityFixture( + const { identityKey, identity } = await createOperatorIdentityFixture( "openclaw-bootstrap-node-reject-", ); const client = { @@ -1321,12 +1231,7 @@ export function registerControlUiAndPairingSuite(): void { }; try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["node"], - scopes: [], - }, - }); + const issued = await issueDeviceBootstrapToken(); const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const initial = await connectReq(wsInitial, { skipDefaultAuth: true, @@ -1334,7 +1239,7 @@ export function registerControlUiAndPairingSuite(): void { role: "node", scopes: [], client, - deviceIdentityPath: identityPath, + deviceIdentityKey: identityKey, }); expect(initial.ok).toBe(false); expect( @@ -1363,7 +1268,7 @@ export function registerControlUiAndPairingSuite(): void { role: "node", scopes: [], client, - deviceIdentityPath: identityPath, + deviceIdentityKey: identityKey, }); expect(retry.ok).toBe(false); expect((retry.error?.details as { code?: string } | undefined)?.code).toBe( @@ -1412,7 +1317,7 @@ export function registerControlUiAndPairingSuite(): void { role: "node", scopes: [], client: nodeClient, - deviceIdentityPath: identityPath, + deviceIdentityKey: identityKey, }); expect(initial.ok).toBe(false); wsInitial.close(); diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index f1a1ee8e951..bc205e6da0e 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -166,7 +166,6 @@ export async function applySessionsPatchToStore(params: { : { ...existing, sessionId: randomUUID(), - sessionFile: undefined, updatedAt: Math.max(existing?.updatedAt ?? 0, now), }; if (existing && !existing.sessionId) { diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index fbc2d31e398..753f7ce3398 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -32,15 +32,6 @@ function safeLogValue(value: string): string { return sanitizeForLog(value); } -function isNonEmptyRelativePathInsideRoot(relativePath: string): boolean { - return ( - relativePath !== "" && - relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath) - ); -} - function maybeWarnTrustedHookSource(source: string): void { if (source === "openclaw-workspace") { log.warn( diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 999280b451d..edb2406e6f4 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -10,7 +10,7 @@ import { resolveScopeOutsideRequestedRoles, roleScopesAllow, } from "../shared/operator-scope-compat.js"; -import { rejectPendingPairingRequest } from "./pairing-pending.js"; +import { revokeDeviceBootstrapTokensForDevice } from "./device-bootstrap.js"; import { createAsyncLock, pruneExpiredPending, diff --git a/src/media/media-reference.ts b/src/media/media-reference.ts index e1ba83889ad..2882b2c00dd 100644 --- a/src/media/media-reference.ts +++ b/src/media/media-reference.ts @@ -92,15 +92,6 @@ function maybeLocalPathFromSource(source: string): string | null { return null; } -function relativePathEscapesBase(relativePath: string): boolean { - return ( - relativePath === ".." || - relativePath.startsWith("../") || - relativePath.startsWith("..\\") || - path.isAbsolute(relativePath) - ); -} - async function resolvePathForContainment(candidate: string): Promise { try { return await fs.realpath(candidate); diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 97a47f500c0..e705fcbbd8e 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -346,6 +346,24 @@ describe("media store", () => { }); }, }, + { + name: "persists streamed media bytes in SQLite", + run: async () => { + await withTempStore(async (store) => { + const saved = await store.saveMediaStream( + Readable.from([Buffer.from("stream bytes")]), + "text/plain", + "inbound", + ); + await fs.rm(saved.path, { force: true }); + + const read = await store.readMediaBuffer(saved.id, "inbound"); + + expect(read.buffer.toString("utf8")).toBe("stream bytes"); + await expect(fs.readFile(read.path, "utf8")).resolves.toBe("stream bytes"); + }); + }, + }, { name: "rejects oversized media ID reads before materializing the file", run: async () => { diff --git a/src/media/store.ts b/src/media/store.ts index 008fcd64d3e..404ce434740 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -420,7 +420,9 @@ async function downloadToBuffer( redirectUrl.origin === parsedUrl.origin ? headers : retainSafeHeadersForCrossOriginRedirect(headers); - resolve(downloadToBuffer(redirectUrl, redirectHeaders, maxRedirects - 1, maxBytes)); + resolve( + downloadToBuffer(redirectUrl.toString(), redirectHeaders, maxRedirects - 1, maxBytes), + ); return; } if (!res.statusCode || res.statusCode >= 400) { @@ -848,6 +850,13 @@ export async function saveMediaStream( resolveFinalPath: (result) => path.join(dir, result.id), }), ); + const buffer = await fs.readFile(saved.filePath); + upsertMediaBlob({ + subdir: resolveMediaSubdir(subdir, "saveMediaStream"), + id: saved.result.id, + buffer, + contentType: saved.result.contentType, + }); return buildSavedMediaResult({ dir, id: saved.result.id, diff --git a/src/plugins/installed-plugin-index-store-path.ts b/src/plugins/installed-plugin-index-store-path.ts new file mode 100644 index 00000000000..962c8972180 --- /dev/null +++ b/src/plugins/installed-plugin-index-store-path.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import type { InstalledPluginIndexStoreOptions } from "./installed-plugin-index-store-options.js"; + +const INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installs.json"); + +export function resolveInstalledPluginIndexStorePath( + options: InstalledPluginIndexStoreOptions = {}, +): string { + const env = options.env ?? process.env; + const stateDir = options.stateDir ?? resolveStateDir(env); + return path.join(stateDir, INSTALLED_PLUGIN_INDEX_STORE_PATH); +} diff --git a/src/proxy-capture/env.ts b/src/proxy-capture/env.ts index eb5256edf7a..2a8a27900c1 100644 --- a/src/proxy-capture/env.ts +++ b/src/proxy-capture/env.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Agent } from "node:http"; import process from "node:process"; +import { createAmbientNodeProxyAgent } from "@openclaw/proxyline"; import { resolveDebugProxyCertDir } from "./paths.js"; export const OPENCLAW_DEBUG_PROXY_ENABLED = "OPENCLAW_DEBUG_PROXY_ENABLED"; diff --git a/src/sessions/transcript-events.ts b/src/sessions/transcript-events.ts index f2b49d523ab..80cc19ad9e8 100644 --- a/src/sessions/transcript-events.ts +++ b/src/sessions/transcript-events.ts @@ -28,6 +28,7 @@ export function emitSessionTranscriptUpdate(update: SessionTranscriptUpdate): vo sessionKey: update.sessionKey, message: update.message, messageId: update.messageId, + messageSeq: update.messageSeq, }; const agentId = normalizeOptionalString(normalized.agentId); const sessionId = normalizeOptionalString(normalized.sessionId); diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index 83b9f7b55ce..20700ab4b59 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -7,6 +7,7 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; +import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { readSqliteNumberPragma } from "../infra/sqlite-pragma.test-support.js"; import type { DB as OpenClawAgentKyselyDatabase } from "./openclaw-agent-db.generated.js"; import { @@ -128,6 +129,29 @@ describe("openclaw agent database", () => { }); }); + it("refuses to open newer per-agent schema versions", () => { + const stateDir = createTempStateDir(); + const databasePath = path.join( + stateDir, + "agents", + "worker-1", + "agent", + "openclaw-agent.sqlite", + ); + fs.mkdirSync(path.dirname(databasePath), { recursive: true }); + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(databasePath); + db.exec("PRAGMA user_version = 2;"); + db.close(); + + expect(() => + openOpenClawAgentDatabase({ + agentId: "worker-1", + env: { OPENCLAW_STATE_DIR: stateDir }, + }), + ).toThrow(/newer schema version 2/); + }); + it("enforces one canonical session route per session key", () => { const stateDir = createTempStateDir(); const database = openOpenClawAgentDatabase({ diff --git a/src/state/openclaw-agent-db.ts b/src/state/openclaw-agent-db.ts index b662f5a2a6d..5f6a2dac151 100644 --- a/src/state/openclaw-agent-db.ts +++ b/src/state/openclaw-agent-db.ts @@ -50,6 +50,20 @@ export type OpenClawRegisteredAgentDatabase = { sizeBytes: number | null; }; +function readSqliteUserVersion(db: DatabaseSync): number { + const row = db.prepare("PRAGMA user_version").get() as { user_version?: unknown } | undefined; + return Number(row?.user_version ?? 0); +} + +function assertSupportedAgentSchemaVersion(db: DatabaseSync, pathname: string): void { + const userVersion = readSqliteUserVersion(db); + if (userVersion > OPENCLAW_AGENT_SCHEMA_VERSION) { + throw new Error( + `OpenClaw agent database ${pathname} uses newer schema version ${userVersion}; this OpenClaw build supports ${OPENCLAW_AGENT_SCHEMA_VERSION}.`, + ); + } +} + export function resolveOpenClawAgentSqlitePath(options: OpenClawAgentDatabaseOptions): string { const agentId = normalizeAgentId(options.agentId); return ( @@ -75,7 +89,8 @@ function ensureOpenClawAgentDatabasePermissions(pathname: string): void { } } -function ensureAgentSchema(db: DatabaseSync, agentId: string): void { +function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname?: string): void { + assertSupportedAgentSchemaVersion(db, pathname ?? `openclaw-agent:${agentId}`); db.exec(OPENCLAW_AGENT_SCHEMA_SQL); db.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION};`); const now = Date.now(); @@ -110,7 +125,7 @@ export function ensureOpenClawAgentDatabaseSchema( options: OpenClawAgentDatabaseOptions & { register?: boolean }, ): void { const agentId = normalizeAgentId(options.agentId); - ensureAgentSchema(db, agentId); + ensureAgentSchema(db, agentId, resolveOpenClawAgentSqlitePath({ ...options, agentId })); if (options.register === true) { const pathname = resolveOpenClawAgentSqlitePath({ ...options, agentId }); registerAgentDatabase({ agentId, path: pathname, env: options.env }); @@ -196,7 +211,13 @@ export function openOpenClawAgentDatabase( db.exec("PRAGMA synchronous = NORMAL;"); db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); db.exec("PRAGMA foreign_keys = ON;"); - ensureAgentSchema(db, agentId); + try { + ensureAgentSchema(db, agentId, pathname); + } catch (err) { + walMaintenance.close(); + db.close(); + throw err; + } ensureOpenClawAgentDatabasePermissions(pathname); const database = { agentId, db, path: pathname, walMaintenance }; cachedDatabases.set(pathname, database); diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 1cfd7a14678..75fd5480733 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -7,6 +7,7 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; +import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { readSqliteNumberPragma } from "../infra/sqlite-pragma.test-support.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; import { @@ -83,6 +84,22 @@ describe("openclaw state database", () => { ).toEqual({ role: "global", schema_version: 1 }); }); + it("refuses to open newer global schema versions", () => { + const stateDir = createTempStateDir(); + const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); + fs.mkdirSync(path.dirname(databasePath), { recursive: true }); + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(databasePath); + db.exec("PRAGMA user_version = 2;"); + db.close(); + + expect(() => + openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: stateDir }, + }), + ).toThrow(/newer schema version 2/); + }); + it("does not chmod shared parent directories for explicit database paths", () => { const databasePath = path.join( os.tmpdir(), diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index a758ee0bbe1..da19d5ee0ee 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -75,6 +75,20 @@ type OpenClawStateMetadataDatabase = Pick< "backup_runs" | "migration_runs" | "migration_sources" | "schema_meta" >; +function readSqliteUserVersion(db: DatabaseSync): number { + const row = db.prepare("PRAGMA user_version").get() as { user_version?: unknown } | undefined; + return Number(row?.user_version ?? 0); +} + +function assertSupportedSchemaVersion(db: DatabaseSync, pathname: string): void { + const userVersion = readSqliteUserVersion(db); + if (userVersion > OPENCLAW_STATE_SCHEMA_VERSION) { + throw new Error( + `OpenClaw state database ${pathname} uses newer schema version ${userVersion}; this OpenClaw build supports ${OPENCLAW_STATE_SCHEMA_VERSION}.`, + ); + } +} + function ensureOpenClawStatePermissions(pathname: string, env: NodeJS.ProcessEnv): void { const dir = path.dirname(pathname); const defaultDir = resolveOpenClawStateSqliteDir(env); @@ -96,7 +110,8 @@ function ensureOpenClawStatePermissions(pathname: string, env: NodeJS.ProcessEnv } } -function ensureSchema(db: DatabaseSync): void { +function ensureSchema(db: DatabaseSync, pathname: string): void { + assertSupportedSchemaVersion(db, pathname); db.exec(OPENCLAW_STATE_SCHEMA_SQL); db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`); const now = Date.now(); @@ -155,7 +170,13 @@ export function openOpenClawStateDatabase( db.exec("PRAGMA synchronous = NORMAL;"); db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); db.exec("PRAGMA foreign_keys = ON;"); - ensureSchema(db); + try { + ensureSchema(db, pathname); + } catch (err) { + walMaintenance.close(); + db.close(); + throw err; + } ensureOpenClawStatePermissions(pathname, env); cachedDatabase = { db, path: pathname, walMaintenance }; return cachedDatabase;