diff --git a/src/agents/exec-defaults.test.ts b/src/agents/exec-defaults.test.ts index 403720913075..637ee31fff8b 100644 --- a/src/agents/exec-defaults.test.ts +++ b/src/agents/exec-defaults.test.ts @@ -338,6 +338,18 @@ describe("resolveExecDefaults", () => { ).toEqual({ canExec: false, node: "build-mac" }); }); + it("uses an explicitly loaded approval snapshot for read-only callers", () => { + const load = vi.mocked(execApprovals.loadExecApprovals); + + expect( + resolveNodeExecEligibility({ + cfg: withDefaultAgent({ tools: { exec: { host: "node", mode: "full" } } }), + execApprovals: { version: 1, defaults: { security: "deny" }, agents: {} }, + }), + ).toEqual({ canExec: false }); + expect(load).not.toHaveBeenCalled(); + }); + it("blocks node skill eligibility when the gateway denies system.run", () => { expect( resolveNodeExecEligibility({ diff --git a/src/agents/exec-defaults.ts b/src/agents/exec-defaults.ts index 9ba91679d771..f93c6278aa51 100644 --- a/src/agents/exec-defaults.ts +++ b/src/agents/exec-defaults.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadExecApprovals, type ExecAsk, + type ExecApprovalsFile, type ExecHost, type ExecMode, type ExecSecurity, @@ -112,6 +113,7 @@ function resolveExecConfigState(params: { /** Resolves whether node exec is usable and any effective node binding. */ export function resolveNodeExecEligibility(params: { cfg?: OpenClawConfig; + execApprovals?: ExecApprovalsFile; sessionEntry?: ExecSessionDefaults; execOverrides?: ExecPolicyOverrides; agentId?: string; @@ -131,6 +133,7 @@ export function resolveNodeExecEligibility(params: { /** Resolves effective exec host, mode, approval policy, and node availability. */ export function resolveExecDefaults(params: { cfg?: OpenClawConfig; + execApprovals?: ExecApprovalsFile; sessionEntry?: ExecSessionDefaults; execOverrides?: ExecPolicyOverrides; agentId?: string; @@ -173,7 +176,7 @@ export function resolveExecDefaults(params: { resolved.effectiveHost === "sandbox" ? undefined : resolveExecApprovalsFromFile({ - file: loadExecApprovals(), + file: params.execApprovals ?? loadExecApprovals(), agentId: resolvedAgentId, overrides: { security: defaultSecurity, diff --git a/src/commands/channels/status.runtime.ts b/src/commands/channels/status.runtime.ts index 05ec0698c98e..917f41c54056 100644 --- a/src/commands/channels/status.runtime.ts +++ b/src/commands/channels/status.runtime.ts @@ -231,7 +231,7 @@ export async function renderChannelsStatusFallback(params: { runtime.error( `${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`, ); - const cfg = await requireValidConfig(runtime); + const cfg = await requireValidConfig(runtime, { observe: false }); if (!cfg) { return; } @@ -242,7 +242,7 @@ export async function renderChannelsStatusFallback(params: { mode: "read_only_status", runtime, }); - const snapshot = await readConfigFileSnapshot(); + const snapshot = await readConfigFileSnapshot({ observe: false }); const mode = cfg.gateway?.mode === "remote" ? "remote" : "local"; const requestedChannel = opts.channel ? (normalizeChannelId(opts.channel) ?? normalizeOptionalLowercaseString(opts.channel)) diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 1177100ee78d..99fb585c049b 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -82,6 +82,17 @@ describe("requireValidConfig", () => { expect(readConfigFileSnapshot).toHaveBeenCalledWith({ skipPluginValidation: true }); }); + it("can validate config without observing persistent health state", async () => { + createValidSnapshot(); + const runtime = createRuntime(); + + await expect(requireValidConfig(runtime, { observe: false })).resolves.toEqual({ + plugins: {}, + }); + + expect(readConfigFileSnapshot).toHaveBeenCalledWith({ observe: false }); + }); + it("emits a non-blocking compatibility advisory when explicitly requested", async () => { createValidSnapshot(); const runtime = createRuntime(); diff --git a/src/commands/config-validation.ts b/src/commands/config-validation.ts index b69002da6920..c0cd1f48a1db 100644 --- a/src/commands/config-validation.ts +++ b/src/commands/config-validation.ts @@ -17,10 +17,18 @@ import type { RuntimeEnv } from "../runtime.js"; /** Read the config file and exit through the runtime when validation fails. */ export async function requireValidConfigFileSnapshot( runtime: RuntimeEnv, - opts?: { includeCompatibilityAdvisory?: boolean; skipPluginValidation?: boolean }, + opts?: { + includeCompatibilityAdvisory?: boolean; + observe?: boolean; + skipPluginValidation?: boolean; + }, ): Promise { + const readOptions = { + ...(opts?.observe === false ? { observe: false } : {}), + ...(opts?.skipPluginValidation ? { skipPluginValidation: true } : {}), + }; const snapshot = await readConfigFileSnapshot( - opts?.skipPluginValidation ? { skipPluginValidation: true } : undefined, + Object.keys(readOptions).length > 0 ? readOptions : undefined, ); if (snapshot.exists && !snapshot.valid) { const issues = @@ -59,7 +67,11 @@ export async function requireValidConfigFileSnapshot( /** Read and return a valid OpenClaw config, or null after reporting validation errors. */ export async function requireValidConfig( runtime: RuntimeEnv, - opts?: { includeCompatibilityAdvisory?: boolean; skipPluginValidation?: boolean }, + opts?: { + includeCompatibilityAdvisory?: boolean; + observe?: boolean; + skipPluginValidation?: boolean; + }, ): Promise { return (await requireValidConfigFileSnapshot(runtime, opts))?.config ?? null; } diff --git a/src/commands/status-all/report-data.test.ts b/src/commands/status-all/report-data.test.ts index f2091e22a534..2acb9d61480a 100644 --- a/src/commands/status-all/report-data.test.ts +++ b/src/commands/status-all/report-data.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ resolveStatusGatewayDiagnosticsSafe: vi.fn(async () => null), resolveStatusGatewayHealthSafe: vi.fn(async () => undefined), resolveNodeExecEligibility: vi.fn(() => ({ canExec: false })), + loadExecApprovalsReadOnly: vi.fn(() => ({ version: 1, agents: {} })), buildWorkspaceSkillStatus: vi.fn(() => null), })); @@ -27,7 +28,12 @@ vi.mock("../../gateway/net.js", () => ({ bindHost === "100.64.0.40" ? [bindHost, "127.0.0.1"] : [bindHost], })); vi.mock("../../infra/ports-inspect.js", () => ({ inspectPortUsage: mocks.inspectPortUsage })); -vi.mock("../../infra/restart-sentinel.js", () => ({ readRestartSentinel: async () => null })); +vi.mock("../../infra/exec-approvals.js", () => ({ + loadExecApprovalsReadOnly: mocks.loadExecApprovalsReadOnly, +})); +vi.mock("../../infra/restart-sentinel.js", () => ({ + readRestartSentinelReadOnly: async () => null, +})); vi.mock("../../plugins/status.js", () => ({ buildPluginCompatibilityNotices: () => [] })); vi.mock("../../skills/discovery/status.js", () => ({ buildWorkspaceSkillStatus: mocks.buildWorkspaceSkillStatus, @@ -172,6 +178,7 @@ describe("buildStatusAllReportData", () => { expect(mocks.resolveNodeExecEligibility).toHaveBeenCalledWith({ cfg: expect.any(Object), + execApprovals: { version: 1, agents: {} }, agentId: "beta", }); expect(mocks.buildWorkspaceSkillStatus).toHaveBeenCalledWith("/tmp/beta", expect.any(Object)); diff --git a/src/commands/status-all/report-data.ts b/src/commands/status-all/report-data.ts index b0a2277cf296..0cb6a6d00631 100644 --- a/src/commands/status-all/report-data.ts +++ b/src/commands/status-all/report-data.ts @@ -5,8 +5,9 @@ import { resolveNodeExecEligibility } from "../../agents/exec-defaults.js"; import { readConfigFileSnapshot, resolveGatewayPort } from "../../config/config.js"; import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js"; import { resolveGatewayBindHost, resolveGatewayRequiredListenHosts } from "../../gateway/net.js"; +import { loadExecApprovalsReadOnly } from "../../infra/exec-approvals.js"; import { inspectPortUsage } from "../../infra/ports-inspect.js"; -import { readRestartSentinel } from "../../infra/restart-sentinel.js"; +import { readRestartSentinelReadOnly } from "../../infra/restart-sentinel.js"; import { resolvePluginControlPlaneWorkspace } from "../../plugins/control-plane-workspace.js"; import { buildPluginCompatibilityNotices } from "../../plugins/status.js"; import { buildWorkspaceSkillStatus } from "../../skills/discovery/status.js"; @@ -58,7 +59,7 @@ async function resolveStatusAllLocalDiagnosis(params: { snap: ConfigFileSnapshot | null; remoteUrlMissing: boolean; secretDiagnostics: StatusScanOverviewResult["secretDiagnostics"]; - sentinel: Awaited> | null; + sentinel: Awaited> | null; lastErr: string | null; port: number; portUsage: Awaited> | null; @@ -111,7 +112,7 @@ async function resolveStatusAllLocalDiagnosis(params: { params.progress.setLabel("Checking local state…"); // These probes are intentionally best-effort so status-all can still print a partial report. - const sentinel = await readRestartSentinel().catch(() => null); + const sentinel = await readRestartSentinelReadOnly().catch(() => null); const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null); const port = resolveGatewayPort(overview.cfg); const bindHost = await resolveGatewayBindHost( @@ -135,6 +136,7 @@ async function resolveStatusAllLocalDiagnosis(params: { // Skill eligibility depends on whether the default agent may request node exec. const nodeSkills = resolveNodeExecEligibility({ cfg: overview.cfg, + execApprovals: loadExecApprovalsReadOnly(), agentId: controlPlaneWorkspace.agentId, }); return buildWorkspaceSkillStatus(defaultWorkspace, { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 2fcad0d94523..83497cbff77f 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -10,7 +10,7 @@ import { import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { withProgress } from "../cli/progress.js"; import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js"; -import { readRestartSentinel } from "../infra/restart-sentinel.js"; +import { readRestartSentinelReadOnly } from "../infra/restart-sentinel.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { assertStatusUsageAgentScope, runStatusJsonCommand } from "./status-json-command.ts"; @@ -319,7 +319,7 @@ export async function statusCommand( nodeOnlyGateway, }); const updateRestartValue = formatUpdateRestartStatusValue( - (await readRestartSentinel().catch(() => null))?.payload, + (await readRestartSentinelReadOnly().catch(() => null))?.payload, { ok, warn, diff --git a/src/commands/status.node-mode.test.ts b/src/commands/status.node-mode.test.ts index 4812d7f9b543..0d414abb09a1 100644 --- a/src/commands/status.node-mode.test.ts +++ b/src/commands/status.node-mode.test.ts @@ -2,22 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - loadNodeHostConfig: vi.fn(), + loadNodeHostConfigReadOnly: vi.fn(), })); vi.mock("../node-host/config.js", () => ({ - loadNodeHostConfig: mocks.loadNodeHostConfig, + loadNodeHostConfigReadOnly: mocks.loadNodeHostConfigReadOnly, })); import { resolveNodeOnlyGatewayInfo } from "./status.node-mode.js"; describe("resolveNodeOnlyGatewayInfo", () => { beforeEach(() => { - mocks.loadNodeHostConfig.mockReset(); + mocks.loadNodeHostConfigReadOnly.mockReset(); }); it("returns node-only gateway details when no local gateway is installed", async () => { - mocks.loadNodeHostConfig.mockResolvedValueOnce({ + mocks.loadNodeHostConfigReadOnly.mockResolvedValueOnce({ version: 1, nodeId: "node-1", gateway: { host: "gateway.example.com", port: 19000 }, @@ -46,7 +46,7 @@ describe("resolveNodeOnlyGatewayInfo", () => { }); it("does not claim node-only mode when the node service is installed but inactive", async () => { - mocks.loadNodeHostConfig.mockResolvedValueOnce({ + mocks.loadNodeHostConfigReadOnly.mockResolvedValueOnce({ version: 1, nodeId: "node-1", gateway: { host: "gateway.example.com", port: 19000 }, @@ -67,7 +67,7 @@ describe("resolveNodeOnlyGatewayInfo", () => { }); it("falls back to an unknown gateway target when node-only config is missing", async () => { - mocks.loadNodeHostConfig.mockResolvedValueOnce(null); + mocks.loadNodeHostConfigReadOnly.mockResolvedValueOnce(null); await expect( resolveNodeOnlyGatewayInfo({ diff --git a/src/commands/status.node-mode.ts b/src/commands/status.node-mode.ts index 9c972c141e13..4f4b2207947d 100644 --- a/src/commands/status.node-mode.ts +++ b/src/commands/status.node-mode.ts @@ -2,7 +2,7 @@ // On these machines the local gateway daemon is absent by design, but the node service may point at a remote gateway. import { DEFAULT_GATEWAY_PORT } from "../config/paths.js"; -import { loadNodeHostConfig } from "../node-host/config.js"; +import { loadNodeHostConfigReadOnly } from "../node-host/config.js"; type NodeOnlyServiceLike = { installed: boolean | null; @@ -66,7 +66,7 @@ export async function resolveNodeOnlyGatewayInfo(params: { return null; } - const gatewayTarget = resolveNodeGatewayTarget((await loadNodeHostConfig())?.gateway); + const gatewayTarget = resolveNodeGatewayTarget((await loadNodeHostConfigReadOnly())?.gateway); return { gatewayTarget, gatewayValue: `node → ${gatewayTarget} · no local gateway`, diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 59f6b90730a6..33d42a70f127 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -17,7 +17,6 @@ const statusSummaryMocks = vi.hoisted(() => ({ entry: Record; }> >(() => []), - configureTaskRegistryMaintenance: vi.fn(), taskRegistrySummary: { total: 0, active: 0, @@ -40,7 +39,7 @@ const statusSummaryMocks = vi.hoisted(() => ({ }, } as TaskRegistrySummary, inspectableTasks: [] as TaskRecord[], - reconcileInspectableTasks: vi.fn(() => statusSummaryMocks.inspectableTasks), + listInspectableTasksReadOnly: vi.fn(() => statusSummaryMocks.inspectableTasks), getInspectableTaskRegistrySummary: vi.fn( (_tasks?: TaskRecord[]) => statusSummaryMocks.taskRegistrySummary, ), @@ -164,8 +163,7 @@ vi.mock("../infra/system-events.js", () => ({ })); vi.mock("../tasks/task-registry.maintenance.js", () => ({ - configureTaskRegistryMaintenance: statusSummaryMocks.configureTaskRegistryMaintenance, - reconcileInspectableTasks: statusSummaryMocks.reconcileInspectableTasks, + listInspectableTasksReadOnly: statusSummaryMocks.listInspectableTasksReadOnly, getInspectableTaskRegistrySummary: statusSummaryMocks.getInspectableTaskRegistrySummary, getInspectableTaskAuditFindings: statusSummaryMocks.getInspectableTaskAuditFindings, })); @@ -490,7 +488,7 @@ describe("getStatusSummary", () => { await getStatusSummary(); - expect(statusSummaryMocks.reconcileInspectableTasks).toHaveBeenCalledTimes(1); + expect(statusSummaryMocks.listInspectableTasksReadOnly).toHaveBeenCalledTimes(1); expect(statusSummaryMocks.getInspectableTaskRegistrySummary).toHaveBeenCalledWith( inspectableTasks, ); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 69575fdb9f2e..25246fedc165 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -753,6 +753,7 @@ vi.mock("../daemon/node-service.js", () => ({ })); vi.mock("../node-host/config.js", () => ({ loadNodeHostConfig: mocks.loadNodeHostConfig, + loadNodeHostConfigReadOnly: mocks.loadNodeHostConfig, })); vi.mock("../tasks/task-registry.maintenance.js", () => ({ getInspectableTaskRegistrySummary: mocks.getInspectableTaskRegistrySummary, diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 451895f855e7..2467d6a398e3 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -3,6 +3,7 @@ import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-c import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { formatCliCommand } from "../cli/command-format.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; import { openOpenClawStateDatabase, runOpenClawStateWriteTransaction, @@ -255,6 +256,29 @@ export async function readRestartSentinel( } } +/** Read the restart sentinel without creating or mutating shared state. */ +export async function readRestartSentinelReadOnly( + env: NodeJS.ProcessEnv = process.env, +): Promise { + try { + const current = withExistingOpenClawStateDatabaseReadOnly( + ({ db }) => readRestartSentinelRowSync(db), + { env }, + ); + if (!current || current.kind === "missing") { + return null; + } + if (current.kind === "invalid") { + sentinelLog.warn("Ignoring invalid typed restart sentinel row"); + return null; + } + return current.sentinel; + } catch (err) { + sentinelLog.warn(`Failed to read restart sentinel: ${formatErrorMessage(err)}`); + return null; + } +} + async function readUpdateInstallReceiptPayload( env: NodeJS.ProcessEnv = process.env, ): Promise { diff --git a/src/node-host/config.ts b/src/node-host/config.ts index ae1b23e5602f..481634f68f38 100644 --- a/src/node-host/config.ts +++ b/src/node-host/config.ts @@ -9,6 +9,7 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; +import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { openOpenClawStateDatabase, @@ -175,7 +176,7 @@ function configToRow(params: { } function readNodeHostConfigRow( - database: ReturnType, + database: Pick, "db">, ): NodeHostConfigRuntimeRow | undefined { return executeSqliteQueryTakeFirstSync( database.db, @@ -208,6 +209,19 @@ export async function loadNodeHostConfig( return row ? rowToNodeHostConfig(row) : null; } +/** Load existing node-host state without creating or joining the writable shared-state lifecycle. */ +export async function loadNodeHostConfigReadOnly( + env: NodeJS.ProcessEnv = process.env, +): Promise { + assertNodeHostLegacyStateMigrated(env); + return ( + withExistingOpenClawStateDatabaseReadOnly(({ db }) => { + const row = readNodeHostConfigRow({ db }); + return row ? rowToNodeHostConfig(row) : null; + }, databaseOptions(env)) ?? null + ); +} + /** * Atomically create or replace the complete node-host snapshot. * Candidate facts are prepared before BEGIN; the transaction rereads the authoritative row. diff --git a/src/status/summary.ts b/src/status/summary.ts index 5efab5b8306e..adf054b2a9b0 100644 --- a/src/status/summary.ts +++ b/src/status/summary.ts @@ -380,8 +380,9 @@ export async function getStatusSummary( const mainSessionKey = resolveSystemMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); const taskMaintenanceModule = await loadTaskRegistryMaintenanceModule(); - taskMaintenanceModule.configureTaskRegistryMaintenance(); - const inspectableTasks = taskMaintenanceModule.reconcileInspectableTasks(); + // Status may overlap a live Gateway, so task inspection must not initialize + // the writable process registry or its schema-owning shared-state handle. + const inspectableTasks = taskMaintenanceModule.listInspectableTasksReadOnly(); const rawTasks = taskMaintenanceModule.getInspectableTaskRegistrySummary(inspectableTasks); const taskAuditFindings = taskMaintenanceModule.getInspectableTaskAuditFindings(inspectableTasks); const now = Date.now(); diff --git a/src/tasks/task-registry.maintenance.ts b/src/tasks/task-registry.maintenance.ts index b17a4e2f5a93..a08d2b6281f1 100644 --- a/src/tasks/task-registry.maintenance.ts +++ b/src/tasks/task-registry.maintenance.ts @@ -60,7 +60,10 @@ import { summarizeTaskAuditFindings, } from "./task-registry.audit.js"; import type { TaskAuditFinding, TaskAuditSummary } from "./task-registry.audit.js"; -import { listTaskRegistryRecordsByRuntimeSourceIdFromSqlite } from "./task-registry.store.sqlite.js"; +import { + listTaskRegistryRecordsByRuntimeSourceIdFromSqlite, + loadTaskRegistryStateFromSqliteReadOnly, +} from "./task-registry.store.sqlite.js"; import { summarizeTaskRecords } from "./task-registry.summary.js"; import type { TaskRecord, TaskRegistrySummary, TaskStatus } from "./task-registry.types.js"; import type { ActiveTaskRestartBlocker } from "./task-restart-blocker.js"; @@ -795,19 +798,30 @@ function reconcileTaskRecordForOperatorInspection( ); } -export function reconcileInspectableTasks(): TaskRecord[] { - taskRegistryMaintenanceRuntime.ensureTaskRegistryReady(); +function reconcileTaskRecordsForOperatorInspection(tasks: TaskRecord[]): TaskRecord[] { const cronRecoveryContext = createCronRecoveryContext(); const backingSessionContext = createBackingSessionLookupContext(); - return taskRegistryMaintenanceRuntime - .listTaskRecords() - .map((task) => - reconcileTaskRecordForOperatorInspectionWithContexts( - task, - cronRecoveryContext, - backingSessionContext, - ), - ); + return tasks.map((task) => + reconcileTaskRecordForOperatorInspectionWithContexts( + task, + cronRecoveryContext, + backingSessionContext, + ), + ); +} + +export function reconcileInspectableTasks(): TaskRecord[] { + taskRegistryMaintenanceRuntime.ensureTaskRegistryReady(); + return reconcileTaskRecordsForOperatorInspection( + taskRegistryMaintenanceRuntime.listTaskRecords(), + ); +} + +/** Reads and reconciles persisted tasks without initializing the process task runtime. */ +export function listInspectableTasksReadOnly(): TaskRecord[] { + return reconcileTaskRecordsForOperatorInspection([ + ...loadTaskRegistryStateFromSqliteReadOnly().tasks.values(), + ]); } configureTaskAuditTaskProvider(reconcileInspectableTasks); diff --git a/src/tasks/task-registry.store.sqlite.ts b/src/tasks/task-registry.store.sqlite.ts index 5f24fd96e556..62ff4210ea33 100644 --- a/src/tasks/task-registry.store.sqlite.ts +++ b/src/tasks/task-registry.store.sqlite.ts @@ -341,8 +341,7 @@ function withWriteTransaction(write: (database: OpenClawStateDatabase) => void) runOpenClawStateWriteTransaction((database) => write(database)); } -export function loadTaskRegistryStateFromSqlite(): TaskRegistryStoreSnapshot { - const { db, path } = openTaskRegistryDatabase(); +function readTaskRegistrySnapshot({ db, path }: TaskRegistryDatabase): TaskRegistryStoreSnapshot { return runSqliteDeferredTransactionSync(db, () => { assertSqliteTableIntegrity(db, path, "task_runs"); assertSqliteTableIntegrity(db, path, "task_delivery_state"); @@ -357,23 +356,17 @@ export function loadTaskRegistryStateFromSqlite(): TaskRegistryStoreSnapshot { }); } +export function loadTaskRegistryStateFromSqlite(): TaskRegistryStoreSnapshot { + return readTaskRegistrySnapshot(openTaskRegistryDatabase()); +} + /** Loads task records without creating or migrating shared state. */ export function loadTaskRegistryStateFromSqliteReadOnly(): TaskRegistryStoreSnapshot { return ( - withExistingOpenClawStateDatabaseReadOnly(({ db, path }) => - runSqliteDeferredTransactionSync(db, () => { - assertSqliteTableIntegrity(db, path, "task_runs"); - assertSqliteTableIntegrity(db, path, "task_delivery_state"); - const taskRows = selectTaskRows(db); - const deliveryRows = selectTaskDeliveryStateRows(db); - return { - tasks: new Map(taskRows.map((row) => [row.task_id, rowToTaskRecord(row)])), - deliveryStates: new Map( - deliveryRows.map((row) => [row.task_id, rowToTaskDeliveryState(row)]), - ), - }; - }), - ) ?? { tasks: new Map(), deliveryStates: new Map() } + withExistingOpenClawStateDatabaseReadOnly(readTaskRegistrySnapshot) ?? { + tasks: new Map(), + deliveryStates: new Map(), + } ); } @@ -395,8 +388,11 @@ export function listTaskRegistryRecordsByRuntimeSourceIdFromSqlite(params: { if (params.sourceId !== undefined && !sourceId) { return []; } - const { db } = openTaskRegistryDatabase(); - return selectTaskRowsByRuntimeSourceId(db, params.runtime, sourceId).map(rowToTaskRecord); + return ( + withExistingOpenClawStateDatabaseReadOnly(({ db }) => + selectTaskRowsByRuntimeSourceId(db, params.runtime, sourceId).map(rowToTaskRecord), + ) ?? [] + ); } export function saveTaskRegistryStateToSqlite(snapshot: TaskRegistryStoreSnapshot) { diff --git a/test/status-shared-state-readonly.e2e.test.ts b/test/status-shared-state-readonly.e2e.test.ts new file mode 100644 index 000000000000..268b1216687c --- /dev/null +++ b/test/status-shared-state-readonly.e2e.test.ts @@ -0,0 +1,106 @@ +// Status shared-state E2E tests enforce the CLI/Gateway SQLite ownership boundary. + +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it } from "vitest"; +import { createOpenClawTestInstance } from "./helpers/openclaw-test-instance.js"; + +function seedInspectableTask(db: DatabaseSync): void { + const now = Date.now(); + // Seed through the persisted schema so the CLI must inspect state owned by + // another process instead of seeing its own in-memory registry. + db.prepare( + `INSERT INTO task_runs ( + task_id, runtime, requester_session_key, owner_key, scope_kind, + child_session_key, agent_id, task, status, delivery_status, + notify_policy, created_at, last_event_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + // Keep the task inside the reconciliation grace window so status should + // report the committed running record unchanged. + "status-read-only-task", + "subagent", + "agent:main:main", + "agent:main:main", + "session", + "agent:main:subagent:status-read-only", + "main", + "Prove status reads shared task state without joining its write lifecycle", + "running", + "pending", + "done_only", + now, + now, + ); +} + +describe("status shared-state ownership", () => { + it.each([ + { name: "text status", args: ["status"] }, + { name: "JSON status", args: ["status", "--json"] }, + { name: "all status", args: ["status", "--all"] }, + { name: "channel probe", args: ["channels", "status", "--probe", "--json"] }, + ])( + "does not create shared state during $name", + async ({ name, args }) => { + const instance = await createOpenClawTestInstance({ + name: `status-read-only-${name.replaceAll(" ", "-")}`, + }); + const databasePath = path.join(instance.stateDir, "state", "openclaw.sqlite"); + try { + expect(fs.existsSync(databasePath)).toBe(false); + + const status = await instance.cli(args); + + expect(status.code, status.stderr).toBe(0); + if (args[0] === "status" && args.includes("--json")) { + expect(JSON.parse(status.stdout)).toMatchObject({ tasks: { total: 0 } }); + } + expect(fs.existsSync(databasePath)).toBe(false); + } finally { + await instance.cleanup(); + } + }, + 120_000, + ); + + it("reads committed tasks while the Gateway owns state and another writer is active", async () => { + const instance = await createOpenClawTestInstance({ name: "status-read-only-live-gateway" }); + const databasePath = path.join(instance.stateDir, "state", "openclaw.sqlite"); + let writer: DatabaseSync | undefined; + try { + await instance.startGateway(); + writer = new DatabaseSync(databasePath); + seedInspectableTask(writer); + // A read-only status path can overlap this writer. Writable schema/bootstrap work cannot. + writer.exec("BEGIN IMMEDIATE"); + + const status = await instance.cli(["status", "--json"], { timeoutMs: 15_000 }); + + expect(status.code, status.stderr).toBe(0); + expect(JSON.parse(status.stdout)).toMatchObject({ tasks: { total: 1 } }); + expect(instance.child?.exitCode).toBeNull(); + + writer.exec("ROLLBACK"); + writer.close(); + writer = undefined; + await instance.stopGateway(); + + const verifier = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect(verifier.prepare("PRAGMA integrity_check").get()).toEqual({ + integrity_check: "ok", + }); + } finally { + verifier.close(); + } + } finally { + if (writer?.isTransaction) { + writer.exec("ROLLBACK"); + } + writer?.close(); + await instance.cleanup(); + } + }, 120_000); +});