fix: restore sqlite runtime build

This commit is contained in:
Peter Steinberger
2026-05-15 06:04:31 +01:00
parent a5c8d3e2f1
commit 138fc66aec
48 changed files with 526 additions and 277 deletions

View File

@@ -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] {

View File

@@ -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)
}

View File

@@ -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) }

View File

@@ -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, {

View File

@@ -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(),
};

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;
},

View File

@@ -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";

View File

@@ -94,7 +94,6 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
resolvePinnedMainDmOwnerFromAllowlist: telegramDeps.resolvePinnedMainDmOwnerFromAllowlist,
}
: {}),
resolveStorePath: telegramDeps.resolveStorePath,
};
const contextRuntime = telegramDeps.recordChannelActivity
? { recordChannelActivity: telegramDeps.recordChannelActivity }

View File

@@ -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"],

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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.

View File

@@ -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,

View File

@@ -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<string, unknown>;
expectFinal?: boolean;
timeoutMs?: number;
}): Promise<unknown> {
return await subagentAnnounceDeliveryDeps.dispatchGatewayMethodInProcess(
"agent",
params.agentParams,
{
expectFinal: params.expectFinal,
forceSyntheticClient: shouldPreserveUserFacingSessionStateForInputProvenance(
params.agentParams.inputProvenance,
),
timeoutMs: params.timeoutMs,
},
);
}
function formatQueueWakeFailureError(
fallback: string,
outcome: EmbeddedAgentQueueMessageOutcome,

View File

@@ -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";

View File

@@ -29,6 +29,15 @@ function getOfficialCatalogFileCache(): Map<string, ChannelCatalogEntryLike[] |
return globals[globalKey];
}
function getBundledPackageCatalogCache(): Map<string, ChannelCatalogEntryLike[] | null> {
const globalKey = "__openclawBundledPackageCatalogCache";
const globals = globalThis as typeof globalThis & {
[globalKey]?: Map<string, ChannelCatalogEntryLike[] | null>;
};
globals[globalKey] ??= new Map<string, ChannelCatalogEntryLike[] | null>();
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 ?? [];

View File

@@ -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") {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<Set<string>>();
const configMutationQueueTails = new Map<string, Promise<void>>();
@@ -173,10 +161,7 @@ async function withConfigMutationLock<T>(
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) {

View File

@@ -1,4 +1,4 @@
import { normalizeStoreSessionKey } from "./store-entry.js";
import { normalizeSessionRowKey } from "./store-entry.js";
export type SessionMaintenancePreserveKeysProvider = () => Iterable<string> | undefined;
@@ -14,10 +14,10 @@ export function registerSessionMaintenancePreserveKeysProvider(
}
function addSessionMaintenancePreserveKey(keys: Set<string>, 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);
}

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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<typeof errorShape> }
> {
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<GatewayRequestContext, "chatAbortControllers">;
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(

View File

@@ -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,

View File

@@ -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<void>((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();

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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,

View File

@@ -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<string> {
try {
return await fs.realpath(candidate);

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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);

View File

@@ -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({

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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;