mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 04:16:13 +00:00
fix: restore sqlite runtime build
This commit is contained in:
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -94,7 +94,6 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
|
||||
resolvePinnedMainDmOwnerFromAllowlist: telegramDeps.resolvePinnedMainDmOwnerFromAllowlist,
|
||||
}
|
||||
: {}),
|
||||
resolveStorePath: telegramDeps.resolveStorePath,
|
||||
};
|
||||
const contextRuntime = telegramDeps.recordChannelActivity
|
||||
? { recordChannelActivity: telegramDeps.recordChannelActivity }
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
src/plugins/installed-plugin-index-store-path.ts
Normal file
13
src/plugins/installed-plugin-index-store-path.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user