From f31d9d8fa929ee23b4f01547434fab886dda00ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 14:04:40 -0700 Subject: [PATCH] fix: prevent externally supervised state schema drift (#121069) * fix: prevent externally supervised state schema drift * refactor: isolate schema ownership support code * test: follow canonical additive column order * test: keep older schema fixture valid * fix: preserve additive schema compatibility * chore: remove release-owned changelog entry * test: follow schema compatibility owner * style: format schema compatibility test * refactor: split sqlite schema sql helpers * fix: preserve desktop schema compatibility * fix: preserve ownership gates across platforms * fix: preserve detached updater long paths * fix: close external state ownership races * chore: refresh plugin sdk api baseline --- .../.generated/plugin-sdk-api-baseline.sha256 | 92 ++--- docs/cli/gateway.md | 13 + docs/reference/database-schemas.md | 26 +- scripts/check-kysely-guardrails.mts | 2 + src/claws/read-only-state-compat.test.ts | 5 +- src/cli/command-catalog.ts | 11 + src/cli/command-startup-policy.test.ts | 1 + src/cli/program/command-registry-core.ts | 5 + src/cli/program/core-command-descriptors.ts | 6 + src/cli/program/register.database.ts | 99 +++++ .../program/root-command-descriptions.test.ts | 2 + src/cli/run-main.exit.test.ts | 36 +- src/cli/run-main.ts | 19 +- src/cli/update-dry-run-state.process.test.ts | 43 ++ src/commands/doctor-config-preflight.ts | 8 + src/commands/doctor-state-sqlite-compact.ts | 12 +- src/config/io.health-state.ts | 9 +- src/gateway/server-startup-bootstrap.ts | 6 + src/infra/node-sqlite.test.ts | 15 +- src/infra/node-sqlite.ts | 15 + src/infra/sqlite-schema-contract.test.ts | 108 ++++- src/infra/sqlite-schema-contract.ts | 390 +++++------------- src/infra/sqlite-schema-issues.ts | 125 ++++++ src/infra/sqlite-schema-sql.ts | 209 ++++++++++ .../startup-migration-checkpoint.test.ts | 86 +++- src/infra/startup-migration-checkpoint.ts | 7 +- ...-managed-service-handoff-ownership.test.ts | 323 +++++++++++++++ src/infra/update-managed-service-handoff.ts | 96 ++++- src/proxy-capture/store.sqlite.test.ts | 29 ++ src/proxy-capture/store.sqlite.ts | 125 +++--- .../openclaw-database-maintenance.test.ts | 4 +- src/state/openclaw-database-preflight.test.ts | 283 ++++++++++++- src/state/openclaw-database-preflight.ts | 153 ++++++- src/state/openclaw-state-db-maintenance.ts | 69 +--- .../openclaw-state-db-startup-checkpoint.ts | 11 +- src/state/openclaw-state-db.ts | 150 +++---- .../openclaw-state-ownership-operations.ts | 173 ++++++++ src/state/openclaw-state-ownership.test.ts | 342 +++++++++++++++ src/state/openclaw-state-ownership.ts | 148 +++++++ .../openclaw-state-schema-compatibility.ts | 123 ++++++ ...teway-external-state-ownership.e2e.test.ts | 83 ++++ 41 files changed, 2885 insertions(+), 577 deletions(-) create mode 100644 src/cli/program/register.database.ts create mode 100644 src/infra/sqlite-schema-issues.ts create mode 100644 src/infra/sqlite-schema-sql.ts create mode 100644 src/infra/update-managed-service-handoff-ownership.test.ts create mode 100644 src/state/openclaw-state-ownership-operations.ts create mode 100644 src/state/openclaw-state-ownership.test.ts create mode 100644 src/state/openclaw-state-ownership.ts create mode 100644 src/state/openclaw-state-schema-compatibility.ts create mode 100644 test/gateway-external-state-ownership.e2e.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 835ee4508320..d3967c1ec0c0 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -3,22 +3,22 @@ a592581a61518734a8a410ec9f71069529ee5fbb54848daf650199eb1cb557ca module/account 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id 64fc61ddb3ed2367ad193c8174e5ddf4f563d7456acd44ab3529219a8d0776fb module/account-resolution 4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives -dc5f4ba23a5831a5f9619049c6f87bdc8d2b9cd272025653750ee84ec0f8b222 module/agent-harness -d0ee7a77ac65b3670dcd5526678e13bc966b76def43137445e377282085ed95e module/agent-harness-runtime +fc111182606f850b49842fc67ef7c0176aa1ca57299e8573e872e35612b719cd module/agent-harness +2b824eb3e9ae741ed5e7f705e6153acf642f022bc288a55b82871175f6b1f16a module/agent-harness-runtime 762a2c3df44621e9464fdd3ef437be2a878bfa9a7e8ca7670f88ae549e601614 module/agent-media-payload -eca72eae4704a6828e07dbcd5e8618c409187324067578e519e7569a03fa6122 module/agent-runtime +e6aa1ac9f4d951fc08b6848491e516486caee62516b6037cfa60087a0bb52e2c module/agent-runtime 66cad8b985017a2487e47d6794dede2d16348f717e7f445cd5df4af2eb7f95f4 module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from f0cd21c1854e209363fc2cbc684ba9dee9668ba7b5d0b57a96d4c8da825d7337 module/allowlist-config-edit -6a76c827ecd4c7f66a9b93533849572d5211da2090a4121758f5a26450a8d77c module/approval-auth-runtime -9765ea0869a5b0b84b97617e82c93f2bf7a1a33df03cfe38e661657c746069c8 module/approval-client-runtime -6b6611999dacdb86603d98b741526b337eed58e7d4a479b71aa849339f71845f module/approval-delivery-runtime +a2fcf1f9e5f0605a239a8511f76a9e37c0b61c50bb944e46ca1eeea848a7d364 module/approval-auth-runtime +bce8b0628bf4aeb8bac4844b05550288b43591e0fc6971b5b3c15b028c6d0ea6 module/approval-client-runtime +70931c3d8b1e14648ee02059bb8198601725d1be26875c5f82c0d232a3a43b3f module/approval-delivery-runtime afba69ce95b3a939511c1f1e6b27a5999e8793304cba7578b0785c7edc342580 module/approval-gateway-runtime a9f10f7c70287d7dc8ad224ca6424234652e74908ae7de2fc7edf4c98c91de4a module/approval-handler-adapter-runtime f8b5c31c956b567827d58e9a1f6493846448b3c0ae30904cc7f4d872b8071d26 module/approval-handler-runtime -cb52e36a0de53874de5115ec797d39e576533cd3dda576c9b6c57af4f2888dca module/approval-native-runtime +2485cc4f41d673a7f285ddcc19313897188e7f21a3ab007904fa333ac88f1866 module/approval-native-runtime b102781a2d78bbfafcf4205aeb2c169ad69bc4010edaf376647036cf3fba48d7 module/approval-reply-runtime -c93d529d020ac1ee52c230e0883f9880656908e8404ef96251bcf7c93afd39f4 module/approval-runtime +c029db9ccff790a9c1065dcd4c478dce7c3c92949996e999b6b0965c9403733f module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param 076689cd8e62c842580d61a34d97bcf42c90406287d428fc5d0d3eb373ec274d module/channel-actions @@ -26,57 +26,57 @@ d1341161e2f5d44eaf7eca3a777ab3f106c7db1db32778bf98c75c7fa7d57ca4 module/channel c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives 9fa7e5f9b3515f0bbc252210b034d39c25de9eb431def30dddce3198c309efac module/channel-config-schema a6af4971aa36345f1a32d97981bd36cc29794f62f30bd57859df2816c65f6fde module/channel-contract -f72c233f06b911825496edb619dfd76fe33e0a52172f2dbf8d5b79969edb983f module/channel-core +f1f206b2b9d4283be0aabc13a05008fd8e4121d85eb540d67379159e157e4e76 module/channel-core 40f8e726de245e17cf7bf908bb7d6fb084f4927f5ccc20b0c089f47af1bd7a0e module/channel-dm-policy -b76423e8c53a0fa835dd3d633edcb147b8cdc3a3e6a9ad7a3b5e54bbbdf2649b module/channel-entry-contract +45fd5148ed2ca6b6fdcf0c244e2b51a2fe4d5b6e4d1c3d9875c0e07a31928b12 module/channel-entry-contract 56b185eb99eb4981056befd38cd9511b655923934acb1c22aeec7d9c504bdf96 module/channel-feedback -de6f1079345b0e161eaa306b9f59aa33cbf5b7858bccc6c61e946800d6c00472 module/channel-inbound +23673efb723395ee091ba21a986c7b295fc794875c509eb04aa43e06c503bca5 module/channel-inbound 780abefd1600c55da219da43c769c873d071ab616b5e02d22f365677ed5a7be4 module/channel-inbound-debounce b72485da4fe5d7f73352f4474d00e30b749b64c8a2066755e104958cd96036c2 module/channel-ingress-runtime f83762680c0d0aa6fa1bad9e53c70bfdf3694126dbdd9491db764a69ed3f6d67 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -3f0c67dd5b9d53bdec6c7c255228e3579f1746c014b1212b361fbc10c97af7b3 module/channel-message -afc036e15843f795bb295c6d9d4e378cca6d816c29c613ecfb49d47353fc8f3a module/channel-outbound -5e0a4e78fa4877753e944a24afcb52bc18b84a99b56f26550ee83e311eae54bd module/channel-pairing -024aff38b066e93618c36a0ecf825cb4e5ab6de5c4b9b3a4030c27f8252a1ada module/channel-plugin-common +857f5a19fef9eb04e8edb5e04e4977afed577c7c0a1e533a8c41d85d359568c5 module/channel-message +570cdc296476b52d0cc305ca1044a03e23e5a38deaa37cd91607ffc632649eb0 module/channel-outbound +72a5d7dc13f8eb7a65159e2f8619c17ef1bf55fdff0a05e9d23c285cef0d32fa module/channel-pairing +59bcea05361cae2d9f8bc9f1fb70c51eac9761a63826426101271c8b603c3506 module/channel-plugin-common c2c7900e47c0595bbc8a166aa60cfbc5a0e02f9a4c0985d5fd630c7dec4c3dca module/channel-policy -fde35c74c5db0f89b235210a1eca1b4e0712181c456f24793958fa73ee0ce042 module/channel-reply-pipeline +e787a315cec9681a49a9e5d1af8fe4fae345561d30996888e5ef6593faae0a75 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context 32c2e9ea4f954a6b04d6e61b392e63d42b5cc74ccbdc1404c94a4c97463f0ca1 module/channel-secret-basic-runtime 941673d941b3ab9bd553ba9153305af7933019254a504a603907a237d06b5e79 module/channel-secret-runtime -4647a0e849e3aa193af751c71d1b137947887a489b999043618bfaebb1a37002 module/channel-send-result +7f5bc35b328c716022a442804f3e83416ee23eaddb49e771c385660dc6540612 module/channel-send-result 4493cf54a54d7159e27d33884b1cd231cd54a038a85a3d5d3676e149954444a7 module/channel-setup 66df387345c0d64083904f4271228bb1e5660e40b00f42afc5d802d3ff07627f module/channel-status 198ed5042f86da7731dad61bc66f44bcb249fd9e369d79cf4509df172d5cab7f module/channel-streaming 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -4f410c7170f48dff594d3ba06636289cc85295e294f76dd92c14a41f3a699d64 module/command-auth -0bc3a0265e7cc89e47470386571fd7bb1ff4baf7754edf2155556de6df824f2f module/command-auth-native +575158115f2a8528a61e5765781534dda5eee8729b61dd409819dcabd68c1ab4 module/command-auth +b908f15287dc66d3e28a8092eeeffe4bfd3ce2956acabf660c306b213e7185fb module/command-auth-native 396f60be14d8fe1144076cffeafc2e2ddc0379b5e664f9fd45d13a0edc4a9f2f module/command-detection 5155909167a810ca258ec9baf4814e7d495cf25bd287fbb22be55eb1a8d88dfe module/command-primitives-runtime 0ab02df5c3904386bfb062e7dce0a24e2ed835b54dcc0112e367346624cb0efe module/command-status 02011f70a5de5f1861609206038b101d3e9bbf39bf45a3cfaaae56596299225b module/config-contracts 7ee5bc35d30165be33ab229a96dd6a6e3e04c620fa4c41753d568798c514feb3 module/config-mutation -08beaa7b5b8797bf248068c73332847b196c5ce47a74c67e788b3281aea46a97 module/config-runtime +eac10f08466e8513c5b5663b29ca3d4ca76d66858367163c5239f08f33d7024c module/config-runtime 069ca892c9d9c0d82f9a0d1e72970da2b1bc5d3c4fc15e127b18bbf78292cd2a module/conversation-runtime -ef1c0830cfd2fccec7388acdba7d88c3352657e617e5da5291f749233942b095 module/core -943df87a9d7c71a60b588da8c069eadc29cd1dd9d8aafd6bdf558225719f8fa8 module/dedupe-runtime +5c1df24349f58e2f25b42ec4ef79d443d84a7bfeb72b8d3596ad339333dda27a module/core +286659baeb922f9a681d172cb1b69b9fb9e09ada87fd2076d90be684b2333cb7 module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap 9ecd22bc3acae3f9b0556b305c4232abe54a7993b910caa789cba6cf917dd1e2 module/diagnostic-runtime 6d90412b97efcc675d16acc3aeb2752520a613ce4bb25b87e47f84fd6b36736d module/directory-runtime -2c859542e09d467fd6ac2d46d5c3e79a2d707bc517500901c4f8c65f244dd565 module/discord +0a933f3b872c2e8944f962f5229b98c33b69d79044084d64060dac338e59ed57 module/discord f2d69888d0c799e12d97b5f92393906aa843be25f61cb6a6c55d32f1db800a44 module/error-runtime 1caf8cc5552ca5e392599457e3f9616e033de199d9f47c12320cdee617a6b0e9 module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -7919b19db8bf80043ac2fd10d7cdadcc3ec5367752cc6cbe7d0f5b61886276bb module/gateway-runtime +f558a90b52bda7e431cc46005f8735263ee065942925f9d547ae528f218be479 module/gateway-runtime 575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access 77726dc396a269ca62fe958c4c797de54d94aba47b7b166e0b66052b604c4b93 module/health -74252b820ea1d21457f01a4e3b868233737e83b134f3d8b8cecfb2392a9647e7 module/hook-runtime +53e032e2f3a2f434702ce1633b0d2a0bed5cbe5f6ef01d7dc348967395036b9a module/hook-runtime 4d9f7d3c3e59113b493f3cac812b3c117968035af10a751a92f2aca8e2ec8806 module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -785de60e1b38da50cb409722b93b6fbe6ecb9ddf0e6fdecf1024b93c58fcf930 module/inbound-reply-dispatch -7f75e1166081708656b561095753ab05480265a55bc40a8e5ca34e221a4798e2 module/infra-runtime +ae8df9d1b4be308d8851a3555cdcafca95598d03788d7b82c114b34559e2b68e module/inbound-reply-dispatch +97a718b5c0f276c7cb0feab83e5aa2bb0f9a14175236d94d4c9f7a7baeb05137 module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once 2fdc37835ccc4651c580847250adf8e42c165483eb239907aea981a6552e24b4 module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store @@ -85,32 +85,32 @@ e5acf130491ef188b193a3e3dc5284e53a29f7e08278caa188f5ddb96e860dc4 module/logging f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix 8cef237e683b323062fa4134a88a0b5b8ff97f1c5d356654c8062fdbf5eed87c module/media-local-roots f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime -d8e039f3ebb4c4bf7e30217105e2aae84934f73df9e05be27db4f746ab45f1a9 module/media-runtime +d0c103832eac668fbbd1593c1c9e3ee77b4233f4b336a6d94cd2f1b032c3050f module/media-runtime 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store 6e5b09a983a69041873ece5bc3b719c0dc913e5a69e2afdf6c7958889e711097 module/media-understanding 7f74e947cca0cf894869bede29815863d5eacca016d04d6262e9d41b3999ae94 module/media-understanding-runtime -4284e7bab8b48c14c4950f703f6b4fc3e0a23164086e5773b41cb35e1d23db99 module/meeting-runtime +6be577c5c6420114b85857648071d176ab34fb3bf696c06937f3fe6fccaf1119 module/meeting-runtime e5d2b540090c17602a1c36b42559f7b516e20730e06bbdd8606597c66ef2ef95 module/memory-core-host-engine-foundation -58b06d2d025e63e852270e871dbd8bc2c308fbfa815d2b31cb7330a27302a94c module/memory-host-core +4522c5673785bc0209178f452ac982fbb68937d5f530a6425ce71c7cd2fff244 module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets -3b35256be44a7f2f69a1c2ebb33b1028d6bdc8c714a426f3b466a61056c5c5d7 module/model-session-runtime -27d34ee308fa91484d6fa40190b205b1e139a9aaf310850c1e8a5b9912ae7b2e module/models-provider-runtime +577d82ef09e96de6af83a22c9b89cc1ddf2d76964abac5808b102ce04ced294f module/model-session-runtime +e775d34923ab24ed4be8bf60b115e6869a9cd5bfca4d8eb9324c5b67209edce9 module/models-provider-runtime b518b4caa7f138d448fe08f5fc9e5f67da5d4b26aafaee2370feb2300e1baa21 module/native-command-config-runtime 1e8b5e7bd8234919d339e750368cbe6425e7463d7e63eb5200d728cc17d6e959 module/native-command-registry 15d1d490e4f7909d2d563b83988258a9cc5fa1344cffb0cd3dcd7592c0467632 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe f8b250da3eab2f85b2bf358872f910b29e541ea04358498a6deab353fa0520fc module/plugin-config-runtime -63aa620ad90d217e31bb55f448cbdfacf823dd17c1b1005baf5f8122b350d555 module/plugin-entry -a8e736985eb018d9f7545b94875cbb4c1dc4c351ecd3142faf72f5ebfe4f66e7 module/plugin-runtime -e70beb5db3ada1eb416ffc97240c81b3a8b5b8e09ea974b18bc6a7c00904267f module/provider-auth -04183b53b3e6ad09347ac7b63f700cf81edaecfe29e3782cf467f53349fa7930 module/provider-catalog-runtime +c07a21684ee72ef37f8b5e8ac52c7b6a8309297e3d1c46c69e748edda80cfe9c module/plugin-entry +84a52fdffc9c9f9f7eb767186bbd3b4c4e34ecd511e10e63c9b4a9226918557c module/plugin-runtime +a07195b83ca9bef78170bf21cb62996c078c8a1670556d753067fac360d4eac3 module/provider-auth +6c79117a067db2ae467bc9de9e13601f5c65b96fae1cafe73e73644bd866ed97 module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture 913c32adcd94ea9c8b7efac0f7c65231500c49129e638c0682eb632da3d5a0c8 module/question-gateway-runtime f319c52a0f1655de752efa55a983893e56e4afa0727f6cc4896c3238c984e91c module/reply-chunking -2c2e5df7b8ece32b837f152a4aff60a8c674a6b65b62c8c8f5af3afd128a43fe module/reply-dispatch-runtime +43562c8fd0c976227051da084698c9aab757a769685b9630cbda8b8cc4cdbb16 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history ecc6e3190892ff416ce69d26c8ce0aa08a468c8689ca3606c18b0271e2d87597 module/reply-payload -cf0283a960539456c02488b19e77eb789598e0f3a0abd59b215d6b402dad4079 module/reply-runtime +2622e09ff245d5813d305ccb2aadfe485d503484172da9a903508bc4edb344c7 module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk 292405cda407e2569f731212f6f981b12bfe682836d5f6e58c6ae194ba27f144 module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command @@ -118,20 +118,20 @@ aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-wa f7900c5ff5827e1b53a926367d807bd4c81b313ab74fd0409d571ae881226a60 module/runtime-config-snapshot 8e33ccbbe610c6c3ad04a140f7511a16cf406733c8459216b1fb4b1e7f0208d9 module/runtime-env 7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy -b7d824cbec83bf90000c941ed96a4f2ebc298cd21b395fea08004d9f171ac1d4 module/runtime-store +e70b9ecd6750fab9769152846c9807a35b4a19f52419eff8b411140ac45328b3 module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input 5958846179128ea6b29135fb90ce4e40b8df7de63d1c65d1696e52294cbc0f52 module/secret-input-runtime 72ca4634e0038ad6dc80c4ce12eb34014e1fa990c7eabe19bb4501b2722ee685 module/secret-ref-runtime 6db2af355b0d1d46f493a5d34a63a6ea5231dafbd8b896d976a52da681868f67 module/security-runtime -1198f77ee999aecc74f6b033131922dadcab05be774d7893635a24d456b3681b module/session-catalog -faad33f1fb9314e36ec58c2c675cd277d04e1c41554e4cb80a38a79776976f68 module/session-discussion -cd31b3430b196d78604de2682802f95453ce8e32c2222ae5f94241e422a2e1e6 module/session-store-runtime +3f883b7b347154781a94b24af122c3dcce5dca4992b98cec346d26ec77732a7b module/session-catalog +7340c857eec2a09c2abe0fb39c20abd3896c0ddb6ceac7109fa017c7e7d1b592 module/session-discussion +bbfe5fe057d23fd874689776aff2d9aa506877c683e275ee66fa28fd866fc58e module/session-store-runtime e54f5c86a55683028a8c4a7c821cf9b9f11b48f91ae9b08e79f8e94b91eb5fde module/setup 41c4790efda179c600fcc8cc4f2cd9c0505efd86df15a1a840aeb99c839bc627 module/setup-runtime 44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools -dc901275501e473a69a8e9435e1c95096c47f1bb569cf3240c1e82c769fffaa0 module/skill-commands-runtime -623e70e5fa77936c6a15ca3a0ae5cf47713e80ef47525a00fa18c3629ff71fde module/speech-settings +9c36f9dd90bee4b1fd51812862d835b54377bfbe4b9b9387d33ed35863eb924b module/skill-commands-runtime +5f0662802cdf24dd68d09e6fa46c4502580b48499d0c2bdfbf134fc1431e2551 module/speech-settings 1e4df0f48b50a8eb546459af1e947ba143c287e3d9c13706c45b67d411c46ad5 module/ssrf-policy e4940099376e18e34c234d4d90d836cbe234c3e891e40b7b4879168b386b1121 module/ssrf-runtime 3c3c812d2d0c997ec81d117346347b802a3445e8e0d76aa6d0e3259f3cf55f98 module/state-paths @@ -141,11 +141,11 @@ f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string- aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking 50d2fa56b2ad57edd12d74201d18ab43045b5c9da0ab98cc158cebe9bd8b768b module/text-runtime -6a10eaa7c058895f47b47a578f7a055deb065b8215e794203a099fe5d8ce1cf1 module/tool-plugin +f629a2045739ca947fbed8ff7b10e884449b3031443487f0aac83ebccb450a41 module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results 5d4f1dbde1238799e5244b9c0b799aa2fb7d607d0c072d9a70659978825ef4ee module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -13e687addfc0a57760ab390928bbd5c38bd185f0ddf7b511033b76a41c7e3b16 module/webhook-ingress +fe6d60775a9db35b9ad0470899e3174225d65f8d8b052b60e81b0a49bf28fc97 module/webhook-ingress ddd05cb74246c393fef4d3fd8331eddd4db72a000fe45e9e515357b757636854 module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 6566fd44e3a2..046cb63cc8d0 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -160,6 +160,19 @@ Set `OPENCLAW_SUPERVISOR_MODE=external` only when another process manager owns t - OpenClaw self-update is refused so the supervisor can stop the Gateway, replace and finalize the runtime, and restart it safely. - A fresh-process restart writes a bounded SQLite handoff before clean exit. If persistence fails, the Gateway falls back to an in-process restart instead of exiting without a consumable handoff. +An external supervisor can also claim durable ownership of shared-state writes: + +```bash +OPENCLAW_SUPERVISOR_MODE=external \ + openclaw database ownership claim --manager gateway-supervisor --json +``` + +Before claiming, stop and verify every older Gateway, CLI, Doctor, updater, and native app process that can write the shared state database. Pre-contract processes do not understand the ownership row and cannot be retroactively fenced. Claim only after every remaining writer uses ownership-aware code and carries `OPENCLAW_SUPERVISOR_MODE=external`. + +The claim is idempotent for the same stable manager identifier and refuses a different manager. There is no automatic claim or unclaim path. Once claimed, unmarked writable shared-state opens fail before permissions, schema migration, additive repair, compaction, or other mutation. Read-only access remains available. This is protection against accidental unmarked same-user writers, not an authentication or lease protocol. + +For upgrades and rollbacks, have the supervisor create a consolidated WAL-consistent copied snapshot with no SQLite sidecars, then run the target release's own `openclaw database preflight --json` before activation. Numeric schema versions alone do not prove that a same-version additive shape is compatible. See [Database schemas](/reference/database-schemas). + `OPENCLAW_SERVICE_REPAIR_POLICY=external` remains a separate Doctor repair policy. It does not declare runtime ownership; supervisors that need both behaviors should set both variables. External supervisors can negotiate and consume restart handoffs through the hidden machine contract: diff --git a/docs/reference/database-schemas.md b/docs/reference/database-schemas.md index 9d0c77ed2248..9aef96b8b7a2 100644 --- a/docs/reference/database-schemas.md +++ b/docs/reference/database-schemas.md @@ -29,8 +29,30 @@ OpenClaw applies forward-only migrations when it opens an older supported databa Changes may stay at the same schema version only when downgraded readers remain safe. New tables qualify because older builds ignore them. An explicitly compatible column on an existing table qualifies only when its declaration is exactly one bare nullable SQLite `STRICT` datatype: `ANY`, `BLOB`, `INT`, `INTEGER`, `REAL`, or `TEXT`. The declaration cannot have a default, `NOT NULL`, a primary or unique key, a check, a reference, a collation, a generated expression, or another suffix. Constrained existing-table additions require a schema-version bump or a companion table instead. +Matching numeric versions are necessary but not sufficient. A release can add a lazy or startup-repairable table, column, index, or trigger without advancing `user_version`, so two databases at the same version can still have different shapes. OpenClaw validates the canonical table definitions, constraints, indexes, triggers, virtual tables, and table options owned by the running release. + Installing OpenClaw manually through npm bypasses the updater guard. Database open checks still refuse an incompatible build. +## Preflight a target release + +Before activating or rolling back a release, run that target release's CLI against one explicit copied state database: + +```bash +openclaw database preflight --json +``` + +The command does not read the default state directory or mutate the supplied file. It opens the supplied consolidated file as immutable/read-only, compares the target release's own schema contract, and reports one status: + +- `exact`: the copied database matches the target release's runtime schema. Feature-local tables that are intentionally absent until first use do not require repair. +- `startup-repairable`: the numeric version matches and a runtime-owned additive difference remains; startup needs a write to converge the shape. +- `migration-required`: the database is older than the target release. +- `incompatible`: the database is newer, or its same-version shape has blocking drift such as an unexpected column. +- `indeterminate`: the file, integrity metadata, or ownership metadata could not be verified. + +JSON output is identified by `schema: "openclaw.state-schema-preflight.v1"`. + +Use a SQLite online backup or another WAL-aware snapshot produced while the source is safely coordinated. The resulting preflight input must be one consolidated file with no sibling `-wal`, `-shm`, or `-journal`; sidecars make the result `indeterminate`. Do not copy only the main `.sqlite` file from an active WAL database. Preflight the exact runtime that will be activated; a package version or numeric schema version alone does not prove same-version shape compatibility. + ## Agent schema history | Version | Change | First release | @@ -65,7 +87,7 @@ Version 3 was an unshipped development step folded into version 4. | Gateway background verifier | Run the full scan about once daily and log results | | Doctor, backup verification, and compaction | Run the full scan before accepting or rewriting the database | -The Gateway preflight reads schema headers only. The background verifier owns the slower full scan for databases that do not need migration. +The Gateway startup preflight reads schema headers only. `openclaw database preflight` performs the release-local shape comparison for an explicit copied file. The background verifier owns the slower recurring full scan for live databases that do not need migration. Quarantine decisions live only in a dedicated `openclaw-quarantine.sqlite` store, so they survive damage to the databases being quarantined. Verification results are logged. ## Troubleshooting @@ -86,7 +108,7 @@ Since 2026.7.2, `openclaw update` refuses to install a release that cannot open A newer OpenClaw build wrote your databases, and the running build is older. The error names the refusing install — release version, commit, and install root — plus the schema it supports and the schema it found. -Act on the install root, not the version. One release version string spans many `main` commits and several schema levels, so two installs can both call themselves `2026.7.2` and support different schemas. A prerelease version may not exist on the `latest` npm tag at all: check `npm view openclaw dist-tags` before reinstalling, because the tag carrying the schema you need may be `beta`, and reinstalling from `latest` can move you further away. +Act on the install root, not the version. One release version string spans many `main` commits, schema levels, and same-version schema shapes, so two installs can both call themselves `2026.7.2` and still disagree about a database. A prerelease version may not exist on the `latest` npm tag at all: check `npm view openclaw dist-tags` before reinstalling, because the tag carrying the schema you need may be `beta`, and reinstalling from `latest` can move you further away. A linked source checkout is the case where the commit misleads: `openclaw --version` reports the checkout's git HEAD, but the code actually executing is whatever `dist/` was last built. If the install root is a checkout, rebuild it (`pnpm build`) before concluding the version is wrong. diff --git a/scripts/check-kysely-guardrails.mts b/scripts/check-kysely-guardrails.mts index 1a0caf371e2d..7aeed326b7d8 100644 --- a/scripts/check-kysely-guardrails.mts +++ b/scripts/check-kysely-guardrails.mts @@ -64,6 +64,7 @@ const rawSqliteAllowPathGroups = { "src/state/openclaw-state-db-schema-repair.ts", "src/state/openclaw-state-db-startup-checkpoint.ts", "src/state/openclaw-state-db.ts", + "src/state/openclaw-state-ownership-operations.ts", "src/transcripts/sqlite-schema.ts", "src/state/sqlite-schema-shape.test-support.ts", ], @@ -85,6 +86,7 @@ const rawSqliteAllowPathGroups = { "read-only schema preflight and integrity verification access": [ "src/state/openclaw-database-preflight.ts", "src/state/openclaw-database-verify.worker.ts", + "src/state/openclaw-state-ownership.ts", ], "quarantine store must work when other databases are damaged": [ "src/state/openclaw-quarantine-store.ts", diff --git a/src/claws/read-only-state-compat.test.ts b/src/claws/read-only-state-compat.test.ts index 065b50eeb4e4..6beb12665fd0 100644 --- a/src/claws/read-only-state-compat.test.ts +++ b/src/claws/read-only-state-compat.test.ts @@ -4,11 +4,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { CLAW_LAZY_ADDITIVE_STATE_COLUMNS } from "../state/openclaw-state-db-maintenance.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../state/openclaw-state-db.js"; +import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "../state/openclaw-state-schema-compatibility.js"; import { readClawResumeStateReadOnly } from "./package-resume.js"; import { parseClawManifest } from "./schema.js"; import type { ClawSourceIdentity } from "./types.js"; @@ -39,7 +39,8 @@ function createBaseShapeState(params: { )`, ) .run(params.packageRoot, join(params.packageRoot, "CLAW.md"), params.workspace); - for (const column of CLAW_LAZY_ADDITIVE_STATE_COLUMNS) { + for (const column of OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns ?? + []) { const [table, name] = column.split("."); database.db.exec(`ALTER TABLE ${table} DROP COLUMN ${name};`); } diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 49306e3ab131..e5cccbf45852 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -102,6 +102,17 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ // read, validate, migrate, or inherit proxy policy from operator state. policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" }, }, + { + commandPath: ["database"], + // Release-local database inspection must not observe default state or load runtime policy. + policy: { + configGuard: "skip", + loadPlugins: "never", + hideBanner: true, + ensureCliPath: false, + networkProxy: "bypass", + }, + }, { commandPath: ["crestodian"], // hidden alias policy: { configGuard: "skip", loadPlugins: "never", ensureCliPath: false }, diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index bc4f834cfd2b..d7e1ee63cf07 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -26,6 +26,7 @@ describe("command-startup-policy", () => { it("resolves config guard policy for Commander and invocation-aware commands", () => { for (const commandPath of [ ["backup", "create"], + ["database"], ["config"], ["config", "file"], ["config", "validate"], diff --git a/src/cli/program/command-registry-core.ts b/src/cli/program/command-registry-core.ts index 1ba9487f579d..fb3d0adcce79 100644 --- a/src/cli/program/command-registry-core.ts +++ b/src/cli/program/command-registry-core.ts @@ -74,6 +74,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec< loadModule: () => import("./register.backup.js"), exportName: "registerBackupCommand", }, + { + commandNames: ["database"], + loadModule: () => import("./register.database.js"), + exportName: "registerDatabaseCommand", + }, { commandNames: ["migrate"], loadModule: () => import("./register.migrate.js"), diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 87dec59c1be1..fad031589576 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -48,6 +48,12 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([ description: "Create and verify backup archives and SQLite snapshots", hasSubcommands: true, }, + { + name: "database", + description: "Inspect shared-state schema compatibility and write ownership", + hasSubcommands: true, + parentDefaultHelp: true, + }, { name: "migrate", description: "Import state from another agent system", diff --git a/src/cli/program/register.database.ts b/src/cli/program/register.database.ts new file mode 100644 index 000000000000..3aef5dae315c --- /dev/null +++ b/src/cli/program/register.database.ts @@ -0,0 +1,99 @@ +import type { Command } from "commander"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { defaultRuntime, writeRuntimeJson, writeRuntimeStdout } from "../../runtime.js"; +import { + OPENCLAW_DATABASE_SCHEMA_DOCS_URL, + preflightOpenClawStateDatabasePath, +} from "../../state/openclaw-database-preflight.js"; +import { resolveDatabasePath } from "../../state/openclaw-state-db-maintenance.js"; +import { claimOpenClawStateOwnership } from "../../state/openclaw-state-ownership-operations.js"; +import { inspectOpenClawStateOwnershipAtPath } from "../../state/openclaw-state-ownership.js"; +import { applyParentDefaultHelpAction } from "./parent-default-help.js"; + +type DatabaseOutputOptions = { json?: boolean }; + +function writeDatabaseError(error: unknown, json: boolean): void { + const message = formatErrorMessage(error); + if (json) { + writeRuntimeJson(defaultRuntime, { error: message }); + } else { + defaultRuntime.error(message); + } + defaultRuntime.exit(1); +} + +async function runDatabasePreflight(databasePath: string, options: DatabaseOutputOptions) { + const result = await preflightOpenClawStateDatabasePath(databasePath); + if (options.json) { + writeRuntimeJson(defaultRuntime, result); + } else { + const detail = result.reason ?? result.issues[0]?.message; + writeRuntimeStdout( + defaultRuntime, + `Database preflight: ${result.status} (found ${result.foundVersion ?? "unknown"}, target ${result.targetVersion}).${detail ? `\n${detail}` : ""}\nSee ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.\n`, + ); + } + if (result.status === "incompatible" || result.status === "indeterminate") { + defaultRuntime.exit(1); + } +} + +function runDatabaseOwnership(options: DatabaseOutputOptions & { manager?: string }): void { + try { + const databasePath = resolveDatabasePath({ env: process.env }); + const ownership = + options.manager !== undefined + ? claimOpenClawStateOwnership(options.manager, { + path: databasePath, + env: process.env, + }) + : inspectOpenClawStateOwnershipAtPath(databasePath); + const status = ownership + ? { status: "external" as const, ownership } + : { status: "unowned" as const }; + if (options.json) { + writeRuntimeJson(defaultRuntime, { databasePath, ...status }); + return; + } + const message = + status.status === "external" + ? `Shared state is externally owned by ${status.ownership.managerId}.` + : "Shared state is not externally owned."; + writeRuntimeStdout(defaultRuntime, `${message}\nSee ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.\n`); + } catch (error) { + writeDatabaseError(error, options.json === true); + } +} + +export function registerDatabaseCommand(program: Command): void { + const database = program + .command("database") + .description("Inspect shared-state schema compatibility and write ownership") + .addHelpText("after", `\nDocs: ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}\n`); + + database + .command("preflight") + .description("Compare one copied SQLite file with this release's state schema") + .argument("", "explicit copied SQLite database path") + .option("--json", "emit machine-readable JSON", false) + .action(async (databasePath: string, options: DatabaseOutputOptions) => { + await runDatabasePreflight(databasePath, options); + }); + + const ownership = database.command("ownership").description("Inspect or claim write ownership"); + ownership + .command("status") + .description("Show durable shared-state write ownership") + .option("--json", "emit machine-readable JSON", false) + .action((options: DatabaseOutputOptions) => runDatabaseOwnership(options)); + ownership + .command("claim") + .description("Claim shared-state writes for the active external supervisor") + .requiredOption("--manager ", "stable external manager identifier") + .option("--json", "emit machine-readable JSON", false) + .action((options: DatabaseOutputOptions & { manager: string }) => + runDatabaseOwnership(options), + ); + applyParentDefaultHelpAction(ownership); + applyParentDefaultHelpAction(database); +} diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 770c8594bbca..49ad5f392003 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -28,6 +28,8 @@ const JSON_NOT_APPLICABLE = { commands: [ "backup", "backup sqlite", + "database", + "database ownership", "message", "message thread", "message emoji", diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 046ae957230f..422eeb863ea6 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -164,6 +164,7 @@ const createCliProgressMock = vi.hoisted(() => })), ); const loadConfigMock = vi.hoisted(() => vi.fn(() => ({}))); +const readSourceConfigBestEffortMock = vi.hoisted(() => vi.fn(async () => ({}))); const startProxyMock = vi.hoisted(() => vi.fn<(config: unknown) => Promise>(async () => null), ); @@ -428,6 +429,7 @@ vi.mock("./progress.js", () => ({ vi.mock("../config/io.js", () => ({ readBestEffortConfig: loadConfigMock, + readSourceConfigBestEffort: readSourceConfigBestEffortMock, })); vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({ @@ -2367,27 +2369,24 @@ describe("runCli exit behavior", () => { it.each([ ["root command", ["node", "openclaw", "update", "--dry-run", "--json"]], ["root shorthand", ["node", "openclaw", "--update", "--dry-run", "--json"]], - ])("reads proxy config without observation for the update dry-run %s", async (_name, argv) => { + ])("reads source-only proxy config for the update dry-run %s", async (_name, argv) => { tryRouteCliMock.mockResolvedValueOnce(true); - loadConfigMock.mockReturnValueOnce({ proxy: { selected: "dry-run" } }); + readSourceConfigBestEffortMock.mockResolvedValueOnce({ proxy: { selected: "dry-run" } }); await runCli(argv); - expect(loadConfigMock).toHaveBeenCalledWith({ - observe: false, - skipPluginValidation: true, - }); + expect(readSourceConfigBestEffortMock).toHaveBeenCalledOnce(); + expect(loadConfigMock).not.toHaveBeenCalled(); expect(startProxyMock).toHaveBeenCalledWith({ selected: "dry-run" }); }); - it("keeps observed proxy config reads for mutable updates", async () => { + it("reads source-only proxy config for mutable updates", async () => { tryRouteCliMock.mockResolvedValueOnce(true); await runCli(["node", "openclaw", "update"]); - expect(loadConfigMock).toHaveBeenCalledWith({ - skipPluginValidation: true, - }); + expect(readSourceConfigBestEffortMock).toHaveBeenCalledOnce(); + expect(loadConfigMock).not.toHaveBeenCalled(); expect(startProxyMock).toHaveBeenCalledWith(undefined); }); @@ -2565,6 +2564,23 @@ describe("runCli exit behavior", () => { expect(loadDotEnvMock).toHaveBeenCalledWith({ loadGlobalEnv: true, quiet: true }); }); + it("keeps explicit database preflight isolated from default state selection", async () => { + tryRouteCliMock.mockResolvedValueOnce(true); + + await runCli([ + "node", + "openclaw", + "database", + "preflight", + "/tmp/openclaw-candidate.sqlite", + "--json", + ]); + + expect(loadDotEnvMock).not.toHaveBeenCalled(); + expect(loadConfigMock).not.toHaveBeenCalled(); + expect(startProxyMock).not.toHaveBeenCalled(); + }); + it("keeps agent exec outside the CLI dotenv loader", async () => { buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync: vi.fn() }); await runCli(["node", "openclaw", "agent", "exec", "test prompt"]); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 7017ab0f872a..184ed97f1617 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -1155,6 +1155,7 @@ async function runCliWithPreparedOutputMode( const normalizedInvocation = resolveCliArgvInvocation(normalizedArgv); const isHelpOrVersionInvocation = normalizedInvocation.hasHelpOrVersion; const isGatewayRunInvocation = isGatewayRunInvocationArgv(normalizedArgv); + const isDatabaseInvocation = normalizedInvocation.commandPath[0] === "database"; // Gateway pre-bootstrap owns state/config dotenv selection. This phase only // needs the workspace file, so avoid importing the loader when it is absent. const loadGlobalEnv = !isGatewayRunInvocation; @@ -1165,6 +1166,7 @@ async function runCliWithPreparedOutputMode( if ( !isHelpOrVersionInvocation && + !isDatabaseInvocation && !isAgentExecInvocation(normalizedInvocation.commandPath) && shouldLoadCliDotEnv(loadGlobalEnv) ) { @@ -1212,12 +1214,14 @@ async function runCliWithPreparedOutputMode( }).skipConfigGuard; const readBestEffortCliConfig = async (): Promise => { if (!bestEffortConfigPromise) { - bestEffortConfigPromise = import("../config/io.js").then(({ readBestEffortConfig }) => - readBestEffortConfig({ - ...(isolateProxyConfigEnv ? { isolateEnv: true, observe: false } : {}), - ...(skipBestEffortConfigObservation ? { observe: false } : {}), - skipPluginValidation: true, - }), + bestEffortConfigPromise = import("../config/io.js").then((configIo) => + normalizedInvocation.primary === "update" + ? configIo.readSourceConfigBestEffort() + : configIo.readBestEffortConfig({ + ...(isolateProxyConfigEnv ? { isolateEnv: true, observe: false } : {}), + ...(skipBestEffortConfigObservation ? { observe: false } : {}), + skipPluginValidation: true, + }), ); } return await bestEffortConfigPromise; @@ -1226,6 +1230,7 @@ async function runCliWithPreparedOutputMode( (trace): trace is ReturnType => Boolean(trace), ); if ( + !isDatabaseInvocation && (await Promise.all(startupTraces.map((trace) => trace.requiresDiagnosticsConfig()))).some( Boolean, ) @@ -1452,7 +1457,7 @@ async function runCliWithPreparedOutputMode( return; } - if (!isHelpOrVersionInvocation) { + if (!isHelpOrVersionInvocation && !isDatabaseInvocation) { await bootstrapCliProxyCaptureAndDispatcher(startupTrace, { ensureDispatcher: shouldUseCliEnvProxy, }); diff --git a/src/cli/update-dry-run-state.process.test.ts b/src/cli/update-dry-run-state.process.test.ts index 5c89afa5707c..ee0848a35662 100644 --- a/src/cli/update-dry-run-state.process.test.ts +++ b/src/cli/update-dry-run-state.process.test.ts @@ -5,6 +5,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { claimOpenClawStateOwnership } from "../state/openclaw-state-ownership-operations.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -67,6 +72,7 @@ function runUpdateDryRun(root: string, args: string[]) { OPENCLAW_HOME: root, OPENCLAW_NO_RESPAWN: "1", OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_SUPERVISOR_MODE: undefined, VITEST: undefined, VITEST_POOL_ID: undefined, VITEST_WORKER_ID: undefined, @@ -154,4 +160,41 @@ describe("update dry-run process state", () => { }).toEqual(markerHashesBefore); expect(snapshotDatabaseArtifacts(await snapshotTree(root))).toEqual(databaseArtifactsBefore); }); + + it("fences the full mutable update path before observation or action", async () => { + const root = tempDirs.make("openclaw-update-owned-state-"); + const configPath = path.join(root, "config", "openclaw.json"); + const stateDir = path.join(root, "state"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, '{ "gateway": { "mode": "local" } }\n'); + const externalEnv = { + ...process.env, + HOME: root, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_HOME: root, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_SUPERVISOR_MODE: "external", + }; + claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv }); + const databasePath = openOpenClawStateDatabase({ env: externalEnv }).path; + closeOpenClawStateDatabaseForTest(); + const before = await snapshotTree(root); + const beforeDatabaseHash = await sha256File(databasePath); + + const refused = runUpdateDryRun(root, [ + "update", + "--timeout", + "invalid", + "--no-restart", + "--json", + ]); + + expect(refused.error).toBeUndefined(); + expect(refused.status).not.toBe(0); + expect(`${refused.stdout}\n${refused.stderr}`).toMatch(/gateway-supervisor/u); + expect(`${refused.stdout}\n${refused.stderr}`).toMatch(/OPENCLAW_SUPERVISOR_MODE=external/u); + expect(`${refused.stdout}\n${refused.stderr}`).not.toMatch(/invalid timeout/iu); + expect(await snapshotTree(root)).toEqual(before); + expect(await sha256File(databasePath)).toBe(beforeDatabaseHash); + }); }); diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index ff934f6518d8..b2edd77b49bc 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -24,6 +24,8 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js"; import { ExitError } from "../runtime.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js"; import { resolveHomeDir } from "../utils.js"; import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js"; import { @@ -205,6 +207,12 @@ export async function runDoctorConfigPreflight( } = {}, ): Promise { const stateMigrationsRequested = options.migrateState !== false; + if (stateMigrationsRequested) { + assertOpenClawStateWriteAllowed({ + databasePath: resolveOpenClawStateSqlitePath(process.env), + env: process.env, + }); + } const measurePreflightStep = (name: string, run: () => T | Promise) => measureDoctorConfigPreflightStep(name, run, options.measure); const gatewayStartupCheckpointRequired = options.requireStartupMigrationCheckpoint === true; diff --git a/src/commands/doctor-state-sqlite-compact.ts b/src/commands/doctor-state-sqlite-compact.ts index 87c61956d817..d85eef00011f 100644 --- a/src/commands/doctor-state-sqlite-compact.ts +++ b/src/commands/doctor-state-sqlite-compact.ts @@ -7,9 +7,9 @@ import { clearOpenClawStateDatabaseOpenFailure, ensureOpenClawStatePermissions, isOpenClawStateDatabaseOpen, - STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS, } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js"; import { compactDoctorSqliteFile, type DoctorSqliteCompactSnapshot, @@ -61,6 +61,7 @@ export async function runDoctorStateSqliteCompact( if (!stat.isFile()) { throw new Error(`Canonical OpenClaw state database is not a regular file: ${sqlitePath}`); } + assertOpenClawStateWriteAllowed({ databasePath: sqlitePath, env }); const withMaintenanceLock = deps.withMaintenanceLock ?? withDoctorSqliteMaintenanceLock; return await withMaintenanceLock({ env, @@ -85,11 +86,10 @@ export async function runDoctorStateSqliteCompact( }, ...(deps.busyTimeoutMs !== undefined ? { busyTimeoutMs: deps.busyTimeoutMs } : {}), sqlitePath, - validateBeforeMutation: (database) => - assertOpenClawStateDatabaseForMaintenance(database, { - pathname: sqlitePath, - allowedMissingColumns: STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS, - }), + validateBeforeMutation: (database) => { + assertOpenClawStateWriteAllowed({ database, databasePath: sqlitePath, env }); + assertOpenClawStateDatabaseForMaintenance(database, { pathname: sqlitePath }); + }, }); return { ...compact, diff --git a/src/config/io.health-state.ts b/src/config/io.health-state.ts index cbc0adff7cd3..317f592f4ceb 100644 --- a/src/config/io.health-state.ts +++ b/src/config/io.health-state.ts @@ -6,6 +6,7 @@ import { openOpenClawStateDatabase, runOpenClawStateWriteTransaction, } from "../state/openclaw-state-db.js"; +import { OpenClawStateOwnershipError } from "../state/openclaw-state-ownership.js"; export type ConfigHealthFingerprint = { hash: string; @@ -94,7 +95,10 @@ export function readConfigHealthStateFromStore(deps: ConfigHealthStateDeps): Con ]), ), }; - } catch { + } catch (error) { + if (error instanceof OpenClawStateOwnershipError) { + throw error; + } return {}; } } @@ -139,6 +143,9 @@ export function writeConfigHealthStateToStore( { env: resolveConfigHealthStateEnv(deps) }, ); } catch (error) { + if (error instanceof OpenClawStateOwnershipError) { + throw error; + } deps.logger.warn(`Config health-state write failed: ${formatErrorMessage(error)}`); } } diff --git a/src/gateway/server-startup-bootstrap.ts b/src/gateway/server-startup-bootstrap.ts index 3e005ff15565..5a8711035be3 100644 --- a/src/gateway/server-startup-bootstrap.ts +++ b/src/gateway/server-startup-bootstrap.ts @@ -40,6 +40,8 @@ import { getTotalQueueSize } from "../process/command-queue.js"; import { getActiveGatewayRootWorkCount } from "../process/gateway-work-admission.js"; import { createLazyPromise } from "../shared/lazy-runtime.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js"; import { ADMIN_SCOPE } from "./method-scopes.js"; import { listCoreGatewayMethodNames } from "./methods/core-descriptors.js"; import { @@ -78,6 +80,10 @@ export async function prepareGatewayServerBootstrap(input: { const { port, opts, log, logSecrets, loadWorkerEnvironmentStartupModule } = input; const formatRuntimeGatewayAuthTokenWarning = input.formatRuntimeGatewayAuthTokenWarning; normalizeStateDirEnv(process.env); + assertOpenClawStateWriteAllowed({ + databasePath: resolveOpenClawStateSqlitePath(process.env), + env: process.env, + }); const [ { OPENCLAW_DATABASE_SCHEMA_DOCS_URL, diff --git a/src/infra/node-sqlite.test.ts b/src/infra/node-sqlite.test.ts index a39415285780..31a95624628b 100644 --- a/src/infra/node-sqlite.test.ts +++ b/src/infra/node-sqlite.test.ts @@ -2,7 +2,11 @@ import path from "node:path"; import { DatabaseSync, type StatementSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { openNodeSqliteDatabase, resolveNodeSqliteLocation } from "./node-sqlite.js"; +import { + openNodeSqliteDatabase, + resolveImmutableSqliteFileUri, + resolveNodeSqliteLocation, +} from "./node-sqlite.js"; const originalPrepare = Reflect.get(DatabaseSync.prototype, "prepare") as DatabaseSync["prepare"]; @@ -126,6 +130,15 @@ describe("node SQLite locations", () => { expect(resolveSpy).toHaveBeenCalledTimes(resolvedPaths.size); expect(namespacedSpy).toHaveBeenCalledTimes(resolvedPaths.size); }); + + it("preserves the Windows long-path namespace in immutable SQLite URIs", () => { + const pathname = String.raw`C:\deep state\openclaw.sqlite`; + const namespacedPath = String.raw`\\?\C:\deep state\openclaw.sqlite`; + + expect(resolveImmutableSqliteFileUri(pathname, "win32")).toBe( + `file:${encodeURIComponent(namespacedPath)}?mode=ro&immutable=1`, + ); + }); }); describe("node SQLite safety", () => { diff --git a/src/infra/node-sqlite.ts b/src/infra/node-sqlite.ts index 091b2967a7b7..7e47708f1d60 100644 --- a/src/infra/node-sqlite.ts +++ b/src/infra/node-sqlite.ts @@ -1,6 +1,7 @@ // Loads node:sqlite with OpenClaw warning handling. import { createRequire } from "node:module"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "./errors.js"; import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js"; import { isSqliteLockError } from "./sqlite-transaction.js"; @@ -29,6 +30,20 @@ export function resolveNodeSqliteLocation(location: string): string { return resolveSqliteFilesystemPath(location); } +/** Build an immutable SQLite URI without losing the Windows long-path namespace. */ +export function resolveImmutableSqliteFileUri( + pathname: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === "win32") { + const namespacedPath = path.win32.toNamespacedPath(path.win32.resolve(pathname)); + // SQLite decodes path escapes after separating the query string, so the + // encoded \\?\ prefix reaches the Windows VFS without becoming URI syntax. + return `file:${encodeURIComponent(namespacedPath)}?mode=ro&immutable=1`; + } + return `${pathToFileURL(path.resolve(pathname)).href}?mode=ro&immutable=1`; +} + function assertSqliteWalResetSafeVersion(version: string, nodeVersion: string): void { if (isSqliteWalResetSafeVersion(version)) { return; diff --git a/src/infra/sqlite-schema-contract.test.ts b/src/infra/sqlite-schema-contract.test.ts index e47f17af7ef8..4c57f893db35 100644 --- a/src/infra/sqlite-schema-contract.test.ts +++ b/src/infra/sqlite-schema-contract.test.ts @@ -1,6 +1,6 @@ import { DatabaseSync } from "node:sqlite"; import { describe, expect, it } from "vitest"; -import { assertSqliteSchemaContains } from "./sqlite-schema-contract.js"; +import { assertSqliteSchemaContains, collectSqliteSchemaIssues } from "./sqlite-schema-contract.js"; const CANONICAL_SCHEMA = ` CREATE TABLE parents ( @@ -248,6 +248,39 @@ describe("assertSqliteSchemaContains", () => { } }); + it("returns a stable missing-table issue", () => { + const database = createDatabase("CREATE TABLE unrelated (id INTEGER PRIMARY KEY);"); + try { + expect(collectSqliteSchemaIssues(database, CANONICAL_SCHEMA)).toContainEqual({ + code: "missing-table", + objectName: "parents", + message: "missing table parents", + }); + } finally { + database.close(); + } + }); + + it("returns a stable virtual-definition issue", () => { + const database = createDatabase( + "CREATE VIRTUAL TABLE search_records USING fts5(body, tokenize='porter');", + ); + try { + expect( + collectSqliteSchemaIssues( + database, + "CREATE VIRTUAL TABLE search_records USING fts5(body);", + ), + ).toContainEqual({ + code: "virtual-table-definition-drift", + objectName: "search_records", + message: "virtual table definition differs for search_records", + }); + } finally { + database.close(); + } + }); + it.each([ { name: "table", @@ -331,6 +364,79 @@ describe("assertSqliteSchemaContains", () => { database.close(); } }); + + it.each([ + { + name: "type", + schema: CANONICAL_SCHEMA.replace("value TEXT NOT NULL", "value BLOB NOT NULL"), + issue: { code: "column-definition-drift", objectName: "parents.value" }, + }, + { + name: "default", + schema: CANONICAL_SCHEMA.replace(" DEFAULT 'pending'", " DEFAULT 'other'"), + issue: { code: "column-definition-drift", objectName: "events.payload" }, + }, + { + name: "nullability", + schema: CANONICAL_SCHEMA.replace("value TEXT NOT NULL", "value TEXT"), + issue: { code: "column-definition-drift", objectName: "parents.value" }, + }, + { + name: "inline primary key", + schema: CANONICAL_SCHEMA.replace("id INTEGER PRIMARY KEY,", "id INTEGER,"), + issue: { code: "column-definition-drift", objectName: "features.id" }, + }, + { + name: "table constraint", + schema: CANONICAL_SCHEMA.replace( + /,\s*FOREIGN KEY \(parent_id\) REFERENCES parents\(id\) ON DELETE CASCADE/u, + "", + ), + issue: { code: "table-constraint-drift", objectName: "children" }, + }, + { + name: "index", + schema: CANONICAL_SCHEMA.replace( + "CREATE INDEX idx_children_parent ON children(parent_id, id)", + "CREATE INDEX idx_children_parent ON children(id, parent_id)", + ), + issue: { code: "missing-or-drifted-index", objectName: "idx_children_parent" }, + }, + { + name: "trigger", + schema: CANONICAL_SCHEMA.replace( + "UPDATE parents SET value = NEW.value WHERE id = NEW.parent_id", + "UPDATE parents SET value = NULL WHERE id = NEW.parent_id", + ), + issue: { + code: "missing-or-drifted-trigger", + objectName: "children_value_after_update", + }, + }, + { + name: "table options", + schema: CANONICAL_SCHEMA.replace( + ` CREATE TABLE parents ( + id TEXT PRIMARY KEY, + value TEXT NOT NULL CHECK (length(value) > 0) + );`, + ` CREATE TABLE parents ( + id TEXT PRIMARY KEY, + value TEXT NOT NULL CHECK (length(value) > 0) + ) STRICT;`, + ), + issue: { code: "table-options-drift", objectName: "parents" }, + }, + ])("returns a stable issue for drifted $name", ({ schema, issue }) => { + const database = createDatabase(schema); + try { + expect(collectSqliteSchemaIssues(database, CANONICAL_SCHEMA)).toContainEqual( + expect.objectContaining(issue), + ); + } finally { + database.close(); + } + }); }); function createDatabase(schema: string): DatabaseSync { diff --git a/src/infra/sqlite-schema-contract.ts b/src/infra/sqlite-schema-contract.ts index c8360faf38b2..c66770486df7 100644 --- a/src/infra/sqlite-schema-contract.ts +++ b/src/infra/sqlite-schema-contract.ts @@ -1,5 +1,26 @@ import type { DatabaseSync } from "node:sqlite"; import { openNodeSqliteDatabase } from "./node-sqlite.js"; +import { + createSqliteSchemaIssue, + legacySqliteSchemaIssueMessages, + throwSqliteSchemaMismatches, + type SqliteSchemaCompatibility, + type SqliteSchemaIssue, + type SqliteSchemaIssueCode, +} from "./sqlite-schema-issues.js"; +import { + findSqlCharacter, + findSqlClosingParenthesis, + normalizeSchemaSql, + normalizeSqlIdentifier, + normalizeSqlWhitespace, + quoteSqliteIdentifier, + readSqlToken, + readTableConstraintKeyword, + splitSqlList, +} from "./sqlite-schema-sql.js"; + +export type { SqliteSchemaCompatibility, SqliteSchemaIssue } from "./sqlite-schema-issues.js"; type SqliteIndexListRow = { name: string; @@ -66,43 +87,7 @@ export type CanonicalSqliteNamedIndexContract = { unique: boolean; }; -export type SqliteSchemaCompatibility = { - /** - * Canonical additive tables that may be absent until their owning feature - * performs its one-time lazy ensure. Present tables still require the exact - * canonical shape. - */ - allowedMissingTables?: readonly string[]; - /** Additive columns that may be absent until their owning feature lazily ensures them. */ - allowedMissingColumns?: readonly string[]; - /** - * Exact definitions produced by supported additive migrations when SQLite - * requires a temporary default that the clean schema does not retain. - */ - allowedColumnDefinitions?: Readonly>; - /** - * Allow unexpected columns declared as a name plus one bare nullable SQLite - * STRICT datatype. Allowed-missing tables remain exact when present. - */ - allowCompatibleAdditiveColumns?: boolean; - /** - * Exact owner-defined trigger groups that may be absent when their derived - * or lazily ensured schema is absent, but must be complete and canonical - * when present. - */ - optionalCanonicalTriggerGroups?: readonly { - /** The trigger group is optional only while this canonical table is absent. */ - optionalWhenTableMissing?: string; - tableName: string; - triggers: readonly { - name: string; - sql: string; - }[]; - }[]; -}; - const schemaContractCache = new Map(); -const TABLE_CONSTRAINT_KEYWORDS = new Set(["CHECK", "FOREIGN", "PRIMARY", "UNIQUE"]); /** * Require every object from one committed schema while allowing unrelated @@ -114,33 +99,52 @@ export function assertSqliteSchemaContains( schemaSql: string, compatibility: SqliteSchemaCompatibility = {}, ): void { + const issues = collectSqliteSchemaIssues(database, schemaSql, compatibility); + if (issues.length > 0) { + throwSqliteSchemaMismatches(databaseLabel, legacySqliteSchemaIssueMessages(issues)); + } +} + +/** Collect stable, machine-readable differences from one committed schema. */ +export function collectSqliteSchemaIssues( + database: DatabaseSync, + schemaSql: string, + compatibility: SqliteSchemaCompatibility = {}, +): SqliteSchemaIssue[] { const expected = getSqliteSchemaContract(schemaSql); const allowedMissingTables = new Set(compatibility.allowedMissingTables ?? []); - const mismatches: string[] = []; + const issues: SqliteSchemaIssue[] = []; + const add = (code: SqliteSchemaIssueCode, objectName: string, message?: string) => { + issues.push(createSqliteSchemaIssue(code, objectName, message)); + }; for (const [tableName, expectedTable] of expected) { const actualTable = collectSqliteTableContract(database, tableName); if (!actualTable) { if (allowedMissingTables.has(tableName)) { continue; } - mismatches.push(`missing table ${tableName}`); + add("missing-table", tableName); continue; } - const definitionMismatch = compareTableDefinitions( - tableName, - actualTable.definition, - expectedTable.definition, - compatibility, - !allowedMissingTables.has(tableName), + issues.push( + ...compareTableDefinitions( + tableName, + actualTable.definition, + expectedTable.definition, + compatibility, + !allowedMissingTables.has(tableName), + ), ); - if (definitionMismatch) { - mismatches.push(`${definitionMismatch} differ for ${tableName}`); - } for (const expectedIndex of expectedTable.indexes) { if (!actualTable.indexes.some((actualIndex) => isEqual(actualIndex, expectedIndex))) { - mismatches.push(`missing or drifted index ${expectedIndex.name ?? `on ${tableName}`}`); + const objectName = expectedIndex.name ?? tableName; + add( + "missing-or-drifted-index", + objectName, + `missing or drifted index ${expectedIndex.name ?? `on ${tableName}`}`, + ); } } for (const actualIndex of actualTable.indexes) { @@ -148,7 +152,12 @@ export function assertSqliteSchemaContains( actualIndex.unique === 1 && !expectedTable.indexes.some((expectedIndex) => isEqual(actualIndex, expectedIndex)) ) { - mismatches.push(`unexpected unique index ${actualIndex.name ?? `on ${tableName}`}`); + const objectName = actualIndex.name ?? tableName; + add( + "unexpected-unique-index", + objectName, + `unexpected unique index ${actualIndex.name ?? `on ${tableName}`}`, + ); } } const optionalCanonicalTriggerGroups = collectOptionalCanonicalTriggerGroups( @@ -171,7 +180,7 @@ export function assertSqliteSchemaContains( continue; } if (!actualTable.triggers.some((actualTrigger) => isEqual(actualTrigger, expectedTrigger))) { - mismatches.push(`missing or drifted trigger ${expectedTrigger.name}`); + add("missing-or-drifted-trigger", expectedTrigger.name); } } for (const triggerGroup of optionalCanonicalTriggerGroups) { @@ -187,7 +196,7 @@ export function assertSqliteSchemaContains( if ( !actualTable.triggers.some((actualTrigger) => isEqual(actualTrigger, canonicalTrigger)) ) { - mismatches.push(`missing or drifted trigger ${canonicalTrigger.name}`); + add("missing-or-drifted-trigger", canonicalTrigger.name); } } } @@ -200,23 +209,20 @@ export function assertSqliteSchemaContains( isEqual(actualTrigger, canonicalTrigger), ) ) { - mismatches.push(`unexpected trigger ${actualTrigger.name}`); + add("unexpected-trigger", actualTrigger.name); } } if (actualTable.virtualTableSql !== expectedTable.virtualTableSql) { - mismatches.push(`virtual table definition differs for ${tableName}`); + add("virtual-table-definition-drift", tableName); } if ( actualTable.strict !== expectedTable.strict || actualTable.withoutRowid !== expectedTable.withoutRowid ) { - mismatches.push(`table options differ for ${tableName}`); + add("table-options-drift", tableName); } } - - if (mismatches.length > 0) { - throwSqliteSchemaMismatches(databaseLabel, mismatches); - } + return issues; } /** Require stable canonical tables before a version-specific additive migration. */ @@ -241,16 +247,6 @@ export function assertSqliteSchemaTablesPresent( } } -function throwSqliteSchemaMismatches(databaseLabel: string, mismatches: string[]): never { - const shown = mismatches.slice(0, 8); - if (mismatches.length > shown.length) { - shown.push(`${mismatches.length - shown.length} additional mismatch(es)`); - } - throw new Error( - `SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`, - ); -} - /** Return every explicit named index owned by one committed schema. */ export function getCanonicalSqliteNamedIndexContracts( schemaSql: string, @@ -443,38 +439,52 @@ function compareTableDefinitions( expected: SqliteTableDefinition | null, compatibility: SqliteSchemaCompatibility, allowCompatibleAdditiveColumns: boolean, -): "column definitions" | "table constraints" | "table definition" | null { +): SqliteSchemaIssue[] { + const issues: SqliteSchemaIssue[] = []; + const add = (code: SqliteSchemaIssueCode, objectName: string) => { + issues.push(createSqliteSchemaIssue(code, objectName)); + }; if (!actual || !expected) { - return actual === expected ? null : "table definition"; + if (actual !== expected) { + add("table-definition-drift", tableName); + } + return issues; } const allowedMissingColumns = new Set(compatibility.allowedMissingColumns ?? []); - const unexpectedColumns = [...actual.columns].filter( - ([columnName]) => !expected.columns.has(columnName), - ); - if ( - unexpectedColumns.some( - ([, definition]) => - !allowCompatibleAdditiveColumns || - !compatibility.allowCompatibleAdditiveColumns || - !isCompatibleAdditiveColumnDefinition(definition), - ) - ) { - return "column definitions"; + for (const [columnName, definition] of actual.columns) { + if (!expected.columns.has(columnName)) { + if ( + allowCompatibleAdditiveColumns && + compatibility.allowCompatibleAdditiveColumns && + isCompatibleAdditiveColumnDefinition(definition) + ) { + continue; + } + const objectName = `${tableName}.${columnName}`; + add("unexpected-column", objectName); + } } for (const [columnName, expectedDefinition] of expected.columns) { + const objectName = `${tableName}.${columnName}`; const actualDefinition = actual.columns.get(columnName); - if (actualDefinition === undefined && allowedMissingColumns.has(`${tableName}.${columnName}`)) { + if (actualDefinition === undefined) { + if (!allowedMissingColumns.has(objectName)) { + add("missing-column", objectName); + } continue; } if (actualDefinition === expectedDefinition) { continue; } - const allowed = compatibility.allowedColumnDefinitions?.[`${tableName}.${columnName}`] ?? []; + const allowed = compatibility.allowedColumnDefinitions?.[objectName] ?? []; if (!allowed.some((definition) => normalizeSqlWhitespace(definition) === actualDefinition)) { - return "column definitions"; + add("column-definition-drift", objectName); } } - return isEqual(actual.constraints, expected.constraints) ? null : "table constraints"; + if (!isEqual(actual.constraints, expected.constraints)) { + add("table-constraint-drift", tableName); + } + return issues; } const SQLITE_STRICT_DATATYPES = new Set(["ANY", "BLOB", "INT", "INTEGER", "REAL", "TEXT"]); @@ -525,59 +535,6 @@ function parseTableDefinition(sql: string | null, tableName: string): SqliteTabl }; } -type SqlToken = { - end: number; - keyword: string | null; - raw: string; -}; - -function readTableConstraintKeyword(sql: string, first: SqlToken): string | null { - let token: SqlToken | null = first; - if (token.keyword === "CONSTRAINT") { - const name = readSqlToken(sql, token.end); - token = name ? readSqlToken(sql, name.end) : null; - } - return token?.keyword && TABLE_CONSTRAINT_KEYWORDS.has(token.keyword) ? token.keyword : null; -} - -function readSqlToken(sql: string, start: number): SqlToken | null { - let index = start; - while (index < sql.length && /\s/u.test(sql[index] ?? "")) { - index += 1; - } - const char = sql[index]; - if (!char) { - return null; - } - if (char === '"' || char === "`") { - const end = skipSqlQuoted(sql, index, char); - return { end, keyword: null, raw: sql.slice(index, end) }; - } - if (char === "[") { - const end = skipSqlQuoted(sql, index, char); - return { end, keyword: null, raw: sql.slice(index, end) }; - } - let end = index; - while (end < sql.length && !/[\s(,]/u.test(sql[end] ?? "")) { - end += 1; - } - const raw = sql.slice(index, end); - return { end, keyword: raw.toUpperCase(), raw }; -} - -function normalizeSqlIdentifier(identifier: string): string { - if (identifier.startsWith('"') && identifier.endsWith('"')) { - return identifier.slice(1, -1).replaceAll('""', '"').toLowerCase(); - } - if (identifier.startsWith("`") && identifier.endsWith("`")) { - return identifier.slice(1, -1).replaceAll("``", "`").toLowerCase(); - } - if (identifier.startsWith("[") && identifier.endsWith("]")) { - return identifier.slice(1, -1).toLowerCase(); - } - return identifier.toLowerCase(); -} - function collectSqliteIndexContract( database: DatabaseSync, index: SqliteIndexListRow, @@ -611,161 +568,6 @@ function sqliteIndexTermKind(cid: number): SqliteIndexTermContract["kind"] { return cid === -2 ? "expression" : cid === -1 ? "rowid" : "column"; } -function normalizeSchemaSql(sql: string | null): string | null { - if (sql === null) { - return null; - } - const normalized = normalizeSqlWhitespace(sql).replace(/;\s*$/u, "").trim(); - return normalized - .replace(/^(CREATE TABLE) IF NOT EXISTS /iu, "$1 ") - .replace(/^(CREATE VIRTUAL TABLE) IF NOT EXISTS /iu, "$1 ") - .replace(/^(CREATE UNIQUE INDEX) IF NOT EXISTS /iu, "$1 ") - .replace(/^(CREATE INDEX) IF NOT EXISTS /iu, "$1 ") - .replace(/^(CREATE TRIGGER) IF NOT EXISTS /iu, "$1 "); -} - -function splitSqlList(sql: string): string[] { - const items: string[] = []; - let depth = 0; - let start = 0; - let index = 0; - while (index < sql.length) { - const next = skipSqlQuotedOrComment(sql, index); - if (next !== index) { - index = next; - continue; - } - const char = sql[index]; - if (char === "(") { - depth += 1; - } else if (char === ")") { - depth -= 1; - } else if (char === "," && depth === 0) { - items.push(sql.slice(start, index)); - start = index + 1; - } - index += 1; - } - items.push(sql.slice(start)); - return items; -} - -function findSqlCharacter(sql: string, character: string): number { - let index = 0; - while (index < sql.length) { - const next = skipSqlQuotedOrComment(sql, index); - if (next !== index) { - index = next; - continue; - } - if (sql[index] === character) { - return index; - } - index += 1; - } - return -1; -} - -function findSqlClosingParenthesis(sql: string, open: number): number { - let depth = 0; - let index = open; - while (index < sql.length) { - const next = skipSqlQuotedOrComment(sql, index); - if (next !== index) { - index = next; - continue; - } - const char = sql[index]; - if (char === "(") { - depth += 1; - } else if (char === ")") { - depth -= 1; - if (depth === 0) { - return index; - } - } - index += 1; - } - throw new Error("SQLite schema contains an unterminated table definition."); -} - -function normalizeSqlWhitespace(sql: string): string { - let normalized = ""; - let pendingSpace = false; - let index = 0; - while (index < sql.length) { - const quoted = skipSqlQuoted(sql, index, sql[index] ?? ""); - if (quoted !== index) { - if (pendingSpace && normalized.length > 0) { - normalized += " "; - } - normalized += sql.slice(index, quoted); - pendingSpace = false; - index = quoted; - continue; - } - const comment = skipSqlComment(sql, index); - if (comment !== index) { - pendingSpace = true; - index = comment; - continue; - } - const char = sql[index] ?? ""; - if (/\s/u.test(char)) { - pendingSpace = true; - } else { - if (pendingSpace && normalized.length > 0) { - normalized += " "; - } - normalized += char; - pendingSpace = false; - } - index += 1; - } - return normalized.trim(); -} - -function skipSqlQuotedOrComment(sql: string, index: number): number { - const quoted = skipSqlQuoted(sql, index, sql[index] ?? ""); - return quoted !== index ? quoted : skipSqlComment(sql, index); -} - -function skipSqlQuoted(sql: string, index: number, quote: string): number { - if (quote !== "'" && quote !== '"' && quote !== "`" && quote !== "[") { - return index; - } - const closingQuote = quote === "[" ? "]" : quote; - let cursor = index + 1; - while (cursor < sql.length) { - if (sql[cursor] !== closingQuote) { - cursor += 1; - continue; - } - if (quote !== "[" && sql[cursor + 1] === closingQuote) { - cursor += 2; - continue; - } - return cursor + 1; - } - return sql.length; -} - -function skipSqlComment(sql: string, index: number): number { - if (sql.startsWith("--", index)) { - const newline = sql.indexOf("\n", index + 2); - return newline === -1 ? sql.length : newline + 1; - } - if (sql.startsWith("/*", index)) { - const close = sql.indexOf("*/", index + 2); - return close === -1 ? sql.length : close + 2; - } - return index; -} - -function quoteSqliteIdentifier(identifier: string): string { - return `"${identifier.replaceAll('"', '""')}"`; -} - function isEqual(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } diff --git a/src/infra/sqlite-schema-issues.ts b/src/infra/sqlite-schema-issues.ts new file mode 100644 index 000000000000..db014e2c97d0 --- /dev/null +++ b/src/infra/sqlite-schema-issues.ts @@ -0,0 +1,125 @@ +export type SqliteSchemaIssueCode = + | "column-definition-drift" + | "missing-column" + | "missing-or-drifted-index" + | "missing-or-drifted-trigger" + | "missing-table" + | "table-constraint-drift" + | "table-definition-drift" + | "table-options-drift" + | "unexpected-column" + | "unexpected-trigger" + | "unexpected-unique-index" + | "virtual-table-definition-drift"; + +export type SqliteSchemaIssue = { + code: SqliteSchemaIssueCode; + message: string; + objectName: string; +}; + +export type SqliteSchemaCompatibility = { + /** + * Canonical additive tables that may be absent until their owning feature + * performs its one-time lazy ensure. Present tables still require the exact + * canonical shape. + */ + allowedMissingTables?: readonly string[]; + /** Additive columns that may be absent until their owning feature lazily ensures them. */ + allowedMissingColumns?: readonly string[]; + /** + * Exact definitions produced by supported additive migrations when SQLite + * requires a temporary default that the clean schema does not retain. + */ + allowedColumnDefinitions?: Readonly>; + /** + * Allow unexpected columns declared as a name plus one bare nullable SQLite + * STRICT datatype. Allowed-missing tables remain exact when present. + */ + allowCompatibleAdditiveColumns?: boolean; + /** + * Exact owner-defined trigger groups that may be absent when their derived + * or lazily ensured schema is absent, but must be complete and canonical + * when present. + */ + optionalCanonicalTriggerGroups?: readonly { + /** The trigger group is optional only while this canonical table is absent. */ + optionalWhenTableMissing?: string; + tableName: string; + triggers: readonly { + name: string; + sql: string; + }[]; + }[]; +}; + +function defaultIssueMessage(code: SqliteSchemaIssueCode, objectName: string): string { + const tableName = objectName.split(".", 1)[0]; + switch (code) { + case "missing-table": + return `missing table ${objectName}`; + case "missing-column": + case "unexpected-column": + case "column-definition-drift": + return `column definitions differ for ${tableName}`; + case "table-constraint-drift": + return `table constraints differ for ${objectName}`; + case "table-definition-drift": + return `table definition differs for ${objectName}`; + case "missing-or-drifted-index": + return `missing or drifted index ${objectName}`; + case "unexpected-unique-index": + return `unexpected unique index ${objectName}`; + case "missing-or-drifted-trigger": + return `missing or drifted trigger ${objectName}`; + case "unexpected-trigger": + return `unexpected trigger ${objectName}`; + case "virtual-table-definition-drift": + return `virtual table definition differs for ${objectName}`; + case "table-options-drift": + return `table options differ for ${objectName}`; + } + const exhaustiveCode: never = code; + throw new Error("Unsupported SQLite schema issue code", { cause: exhaustiveCode }); +} + +export function createSqliteSchemaIssue( + code: SqliteSchemaIssueCode, + objectName: string, + message?: string, +): SqliteSchemaIssue { + return { code, objectName, message: message ?? defaultIssueMessage(code, objectName) }; +} + +export function legacySqliteSchemaIssueMessages(issues: readonly SqliteSchemaIssue[]): string[] { + const isColumnIssue = (issue: SqliteSchemaIssue) => + issue.code === "column-definition-drift" || + issue.code === "missing-column" || + issue.code === "unexpected-column"; + const columnIssueTables = new Set( + issues.filter(isColumnIssue).map((issue) => issue.objectName.split(".", 1)[0]), + ); + return [ + ...new Set( + issues + .filter( + (issue) => + issue.code !== "table-constraint-drift" || !columnIssueTables.has(issue.objectName), + ) + .map((issue) => issue.message), + ), + ]; +} + +export function throwSqliteSchemaMismatches( + databaseLabel: string, + mismatches: readonly string[], +): never { + const shown = mismatches.slice(0, 8); + if (mismatches.length > shown.length) { + shown.push(`${mismatches.length - shown.length} additional mismatch(es)`); + } + throw new Error( + `SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`, + ); +} diff --git a/src/infra/sqlite-schema-sql.ts b/src/infra/sqlite-schema-sql.ts new file mode 100644 index 000000000000..5b6f2a6a8364 --- /dev/null +++ b/src/infra/sqlite-schema-sql.ts @@ -0,0 +1,209 @@ +const TABLE_CONSTRAINT_KEYWORDS = new Set(["CHECK", "FOREIGN", "PRIMARY", "UNIQUE"]); + +type SqlToken = { + end: number; + keyword: string | null; + raw: string; +}; + +export function readTableConstraintKeyword(sql: string, first: SqlToken): string | null { + let token: SqlToken | null = first; + if (token.keyword === "CONSTRAINT") { + const name = readSqlToken(sql, token.end); + token = name ? readSqlToken(sql, name.end) : null; + } + return token?.keyword && TABLE_CONSTRAINT_KEYWORDS.has(token.keyword) ? token.keyword : null; +} + +export function readSqlToken(sql: string, start: number): SqlToken | null { + let index = start; + while (index < sql.length && /\s/u.test(sql[index] ?? "")) { + index += 1; + } + const char = sql[index]; + if (!char) { + return null; + } + if (char === '"' || char === "`") { + const end = skipSqlQuoted(sql, index, char); + return { end, keyword: null, raw: sql.slice(index, end) }; + } + if (char === "[") { + const end = skipSqlQuoted(sql, index, char); + return { end, keyword: null, raw: sql.slice(index, end) }; + } + let end = index; + while (end < sql.length && !/[\s(,]/u.test(sql[end] ?? "")) { + end += 1; + } + const raw = sql.slice(index, end); + return { end, keyword: raw.toUpperCase(), raw }; +} + +export function normalizeSqlIdentifier(identifier: string): string { + if (identifier.startsWith('"') && identifier.endsWith('"')) { + return identifier.slice(1, -1).replaceAll('""', '"').toLowerCase(); + } + if (identifier.startsWith("`") && identifier.endsWith("`")) { + return identifier.slice(1, -1).replaceAll("``", "`").toLowerCase(); + } + if (identifier.startsWith("[") && identifier.endsWith("]")) { + return identifier.slice(1, -1).toLowerCase(); + } + return identifier.toLowerCase(); +} + +export function normalizeSchemaSql(sql: string | null): string | null { + if (sql === null) { + return null; + } + const normalized = normalizeSqlWhitespace(sql).replace(/;\s*$/u, "").trim(); + return normalized + .replace(/^(CREATE TABLE) IF NOT EXISTS /iu, "$1 ") + .replace(/^(CREATE VIRTUAL TABLE) IF NOT EXISTS /iu, "$1 ") + .replace(/^(CREATE UNIQUE INDEX) IF NOT EXISTS /iu, "$1 ") + .replace(/^(CREATE INDEX) IF NOT EXISTS /iu, "$1 ") + .replace(/^(CREATE TRIGGER) IF NOT EXISTS /iu, "$1 "); +} + +export function splitSqlList(sql: string): string[] { + const items: string[] = []; + let depth = 0; + let start = 0; + let index = 0; + while (index < sql.length) { + const next = skipSqlQuotedOrComment(sql, index); + if (next !== index) { + index = next; + continue; + } + const char = sql[index]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + } else if (char === "," && depth === 0) { + items.push(sql.slice(start, index)); + start = index + 1; + } + index += 1; + } + items.push(sql.slice(start)); + return items; +} + +export function findSqlCharacter(sql: string, character: string): number { + let index = 0; + while (index < sql.length) { + const next = skipSqlQuotedOrComment(sql, index); + if (next !== index) { + index = next; + continue; + } + if (sql[index] === character) { + return index; + } + index += 1; + } + return -1; +} + +export function findSqlClosingParenthesis(sql: string, open: number): number { + let depth = 0; + let index = open; + while (index < sql.length) { + const next = skipSqlQuotedOrComment(sql, index); + if (next !== index) { + index = next; + continue; + } + const char = sql[index]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + } + index += 1; + } + throw new Error("SQLite schema contains an unterminated table definition."); +} + +export function normalizeSqlWhitespace(sql: string): string { + let normalized = ""; + let pendingSpace = false; + let index = 0; + while (index < sql.length) { + const quoted = skipSqlQuoted(sql, index, sql[index] ?? ""); + if (quoted !== index) { + if (pendingSpace && normalized.length > 0) { + normalized += " "; + } + normalized += sql.slice(index, quoted); + pendingSpace = false; + index = quoted; + continue; + } + const comment = skipSqlComment(sql, index); + if (comment !== index) { + pendingSpace = true; + index = comment; + continue; + } + const char = sql[index] ?? ""; + if (/\s/u.test(char)) { + pendingSpace = true; + } else { + if (pendingSpace && normalized.length > 0) { + normalized += " "; + } + normalized += char; + pendingSpace = false; + } + index += 1; + } + return normalized.trim(); +} + +export function quoteSqliteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function skipSqlQuotedOrComment(sql: string, index: number): number { + const quoted = skipSqlQuoted(sql, index, sql[index] ?? ""); + return quoted !== index ? quoted : skipSqlComment(sql, index); +} + +function skipSqlQuoted(sql: string, index: number, quote: string): number { + if (quote !== "'" && quote !== '"' && quote !== "`" && quote !== "[") { + return index; + } + const closingQuote = quote === "[" ? "]" : quote; + let cursor = index + 1; + while (cursor < sql.length) { + if (sql[cursor] !== closingQuote) { + cursor += 1; + continue; + } + if (quote !== "[" && sql[cursor + 1] === closingQuote) { + cursor += 2; + continue; + } + return cursor + 1; + } + return sql.length; +} + +function skipSqlComment(sql: string, index: number): number { + if (sql.startsWith("--", index)) { + const newline = sql.indexOf("\n", index + 2); + return newline === -1 ? sql.length : newline + 1; + } + if (sql.startsWith("/*", index)) { + const close = sql.indexOf("*/", index + 2); + return close === -1 ? sql.length : close + 2; + } + return index; +} diff --git a/src/infra/startup-migration-checkpoint.test.ts b/src/infra/startup-migration-checkpoint.test.ts index 896a1ef2c2f8..4aca9357701a 100644 --- a/src/infra/startup-migration-checkpoint.test.ts +++ b/src/infra/startup-migration-checkpoint.test.ts @@ -1,7 +1,7 @@ // Startup migration checkpoint tests cover shared-state version records and leases. import { existsSync, mkdirSync } from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { @@ -11,6 +11,10 @@ import { withOpenClawStateStartupMigrationCheckpointDatabase, } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { + OpenClawStateOwnershipError, + STATE_SUPERVISION_KEY, +} from "../state/openclaw-state-ownership.js"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, @@ -31,6 +35,7 @@ import { afterEach(() => { closeOpenClawStateDatabaseForTest(); + vi.restoreAllMocks(); }); const startupMigrationTempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -268,6 +273,85 @@ describe("startup migration checkpoint", () => { next.release(); }); + it("rechecks external ownership inside the final lease write transaction", () => { + const env = { + OPENCLAW_STATE_DIR: startupMigrationTempDirs.make("openclaw-startup-migration-"), + }; + runOpenClawStateWriteTransaction(() => undefined, { env }); + closeOpenClawStateDatabaseForTest(); + const databasePath = resolveOpenClawStateSqlitePath(env); + const { DatabaseSync } = requireNodeSqlite(); + const originalExec = Object.getOwnPropertyDescriptor(DatabaseSync.prototype, "exec")?.value as + | ((this: import("node:sqlite").DatabaseSync, sql: string) => void) + | undefined; + if (!originalExec) { + throw new Error("DatabaseSync.exec descriptor is unavailable"); + } + // Schema setup commits before the lease helper starts its own transaction. + // Claim at that exact boundary so the final transaction must fence the new owner. + let immediateTransactionCount = 0; + const exec = vi.spyOn(DatabaseSync.prototype, "exec").mockImplementation(function ( + this: import("node:sqlite").DatabaseSync, + sql: string, + ) { + if (sql === "BEGIN IMMEDIATE" && ++immediateTransactionCount === 2) { + const claimant = new DatabaseSync(databasePath); + try { + claimant + .prepare( + `INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) + VALUES (?, ?, ?)`, + ) + .run( + STATE_SUPERVISION_KEY, + JSON.stringify({ + version: 1, + mode: "external", + managerId: "race-manager", + claimedAt: 1, + }), + 1, + ); + } finally { + claimant.close(); + } + } + return originalExec.call(this, sql); + }); + + try { + expect(() => acquireStartupMigrationLease({ env, owner: "unmarked", nowMs: 1 })).toThrow( + OpenClawStateOwnershipError, + ); + } finally { + exec.mockRestore(); + } + + const verify = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + verify + .prepare( + `SELECT COUNT(*) AS count + FROM state_leases + WHERE scope = 'startup-migrations' AND lease_key = 'global'`, + ) + .get(), + ).toEqual({ count: 0 }); + expect( + verify + .prepare( + `SELECT COUNT(*) AS count + FROM schema_meta + WHERE meta_key IN ('state-migrations', 'startup-migrations')`, + ) + .get(), + ).toEqual({ count: 0 }); + } finally { + verify.close(); + } + }); + it("waits for a live same-host startup migration lease to be released", async () => { const env = { OPENCLAW_STATE_DIR: startupMigrationTempDirs.make("openclaw-startup-migration-"), diff --git a/src/infra/startup-migration-checkpoint.ts b/src/infra/startup-migration-checkpoint.ts index 64421094d655..9940841d997c 100644 --- a/src/infra/startup-migration-checkpoint.ts +++ b/src/infra/startup-migration-checkpoint.ts @@ -11,6 +11,7 @@ import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-re import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { withOpenClawStateStartupMigrationCheckpointDatabase } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js"; import { VERSION } from "../version.js"; import { executeSqliteQuerySync, @@ -156,8 +157,12 @@ function writeStartupMigrationCheckpointDatabase( env: NodeJS.ProcessEnv, callback: (db: DatabaseSync) => T, ): T { + const databasePath = resolveOpenClawStateSqlitePath(env); return withStartupMigrationCheckpointDatabase(env, (db) => - runSqliteImmediateTransactionSync(db, () => callback(db)), + runSqliteImmediateTransactionSync(db, () => { + assertOpenClawStateWriteAllowed({ database: db, databasePath, env }); + return callback(db); + }), ); } diff --git a/src/infra/update-managed-service-handoff-ownership.test.ts b/src/infra/update-managed-service-handoff-ownership.test.ts new file mode 100644 index 000000000000..0f8d8af5cd16 --- /dev/null +++ b/src/infra/update-managed-service-handoff-ownership.test.ts @@ -0,0 +1,323 @@ +/** + * Tests externally owned state behavior in the detached managed-service update helper. + */ +import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { claimOpenClawStateOwnership } from "../state/openclaw-state-ownership-operations.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js"; + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +function createSpawnMock() { + return Object.assign(new EventEmitter(), { + pid: 24680, + exitCode: null, + signalCode: null, + stdout: new PassThrough(), + unref: vi.fn(), + }); +} + +vi.mock("node:child_process", async () => { + const { mockNodeChildProcessModule } = + await import("../gateway/server-methods/node-child-process.test-support.js"); + return mockNodeChildProcessModule({ + spawn: spawnMock as unknown as typeof import("node:child_process").spawn, + }); +}); + +const tempDirs = new Set(); +type GatewayRestartSentinelDatabase = Pick; + +beforeEach(() => { + spawnMock.mockReset(); + spawnMock.mockImplementation(() => { + const child = createSpawnMock(); + process.nextTick(() => { + child.stdout.write("OPENCLAW_UPDATE_HANDOFF_READY\n"); + }); + return child; + }); +}); + +afterEach(async () => { + closeOpenClawStateDatabaseForTest(); + await Promise.all([...tempDirs].map((dir) => fs.rm(dir, { recursive: true, force: true }))); + tempDirs.clear(); + vi.resetModules(); +}); + +function writeRestartSentinelRow( + env: NodeJS.ProcessEnv, + sentinel: { + version: 1; + revision: number; + payload: { + kind: string; + status: string; + ts: number; + stats: Record; + }; + }, +): void { + const { db } = openOpenClawStateDatabase({ env }); + const stateDb = getNodeSqliteKysely(db); + executeSqliteQuerySync( + db, + stateDb.insertInto("gateway_restart_sentinel").values({ + sentinel_key: "current", + version: sentinel.version, + kind: sentinel.payload.kind, + status: sentinel.payload.status, + ts: sentinel.payload.ts, + session_key: null, + thread_id: null, + delivery_channel: null, + delivery_to: null, + delivery_account_id: null, + message: null, + continuation_json: null, + doctor_hint: null, + stats_json: JSON.stringify(sentinel.payload.stats), + payload_json: JSON.stringify(sentinel.payload), + updated_at_ms: sentinel.revision, + }), + ); +} + +async function runOwnershipHelper(params: { + handoffId?: string; + metaHandoffId?: string; + prepareStateDatabase: (env: NodeJS.ProcessEnv) => Promise | void; + whileHelperRunning?: (context: { logPath: string }) => Promise | void; +}) { + const { execFile } = + await vi.importActual("node:child_process"); + const { startManagedServiceUpdateHandoff } = await import("./update-managed-service-handoff.js"); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-handoff-ownership-test-")); + tempDirs.add(tmpDir); + const env = { OPENCLAW_STATE_DIR: tmpDir } as NodeJS.ProcessEnv; + + await startManagedServiceUpdateHandoff({ + root: tmpDir, + timeoutMs: 1_800_000, + restartDrainTimeoutMs: 300_000, + restartDelayMs: 500, + parentPid: process.pid, + execPath: "/usr/local/bin/node", + argv1: "/opt/openclaw/openclaw.mjs", + ...(params.handoffId ? { handoffId: params.handoffId } : {}), + env, + meta: { + ...(params.metaHandoffId ? { handoffId: params.metaHandoffId } : {}), + sessionKey: "agent:test:webchat:dm:user-123", + continuationMessage: "continue after restart", + }, + }); + + const [, args, spawnOptions] = spawnMock.mock.calls.at(-1) as unknown as [ + string, + string[], + { env: NodeJS.ProcessEnv; detached?: boolean; cwd?: string }, + ]; + const helperScriptPath = args[0] ?? ""; + tempDirs.add(path.dirname(helperScriptPath)); + const helperParams = JSON.parse(await fs.readFile(args[1] ?? "", "utf8")) as Record< + string, + unknown + >; + await params.prepareStateDatabase(env); + const helperParamsPath = path.join(tmpDir, "helper-params.json"); + const logPath = path.join(tmpDir, "handoff.log"); + await fs.writeFile( + helperParamsPath, + `${JSON.stringify( + { + ...helperParams, + parentPid: process.pid, + parentExitTimeoutMs: 1, + logPath, + sensitivePaths: [], + }, + null, + 2, + )}\n`, + ); + + const resultPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => { + execFile( + process.execPath, + [helperScriptPath, helperParamsPath], + { cwd: tmpDir, env: spawnOptions.env }, + (err) => { + const childError = err as (NodeJS.ErrnoException & { signal?: NodeJS.Signals }) | null; + resolve({ + code: typeof childError?.code === "number" ? childError.code : 0, + signal: childError?.signal ?? null, + }); + }, + ); + }, + ); + await params.whileHelperRunning?.({ logPath }); + return { result: await resultPromise, env, logPath }; +} + +describe("managed service update handoff external ownership", () => { + it("refuses fallback writes to externally owned state without the supervisor marker", async () => { + let before: + | { + bytes: Buffer; + entries: string[]; + ctimeMs: number; + ino: number; + mode: number; + mtimeMs: number; + } + | undefined; + const { result, env, logPath } = await runOwnershipHelper({ + prepareStateDatabase: async (stateEnv) => { + const externalEnv = { ...stateEnv, OPENCLAW_SUPERVISOR_MODE: "external" }; + claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv }); + closeOpenClawStateDatabaseForTest(); + const databasePath = resolveOpenClawStateSqlitePath(stateEnv); + const stat = await fs.stat(databasePath); + before = { + bytes: await fs.readFile(databasePath), + entries: (await fs.readdir(path.dirname(databasePath))).toSorted(), + ctimeMs: stat.ctimeMs, + ino: stat.ino, + mode: stat.mode, + mtimeMs: stat.mtimeMs, + }; + }, + }); + + expect(result).toEqual({ code: 1, signal: null }); + const databasePath = resolveOpenClawStateSqlitePath(env); + const stat = await fs.stat(databasePath); + expect({ + bytes: await fs.readFile(databasePath), + entries: (await fs.readdir(path.dirname(databasePath))).toSorted(), + ctimeMs: stat.ctimeMs, + ino: stat.ino, + mode: stat.mode, + mtimeMs: stat.mtimeMs, + }).toEqual(before); + await expect(fs.readFile(logPath, "utf8")).resolves.toMatch( + /gateway-supervisor.*OPENCLAW_SUPERVISOR_MODE=external/u, + ); + }); + + it("rechecks external ownership after waiting for the state write lock", async () => { + const pendingSentinel = { + version: 1 as const, + revision: 100, + payload: { + kind: "update", + status: "skipped", + ts: 100, + stats: { + handoffId: "handoff-ownership-race", + reason: "managed-service-handoff-started", + }, + }, + }; + const ownership = { + version: 1, + mode: "external", + managerId: "race-supervisor", + claimedAt: Date.now(), + }; + let claimant: import("node:sqlite").DatabaseSync | undefined; + let claimantTransactionOpen = false; + let beforeSentinelRow: unknown; + let helperResult: Awaited> | undefined; + try { + helperResult = await runOwnershipHelper({ + handoffId: "handoff-ownership-race", + metaHandoffId: "handoff-ownership-race", + prepareStateDatabase: async (stateEnv) => { + writeRestartSentinelRow(stateEnv, pendingSentinel); + closeOpenClawStateDatabaseForTest(); + const sqlite = await import("node:sqlite"); + claimant = new sqlite.DatabaseSync(resolveOpenClawStateSqlitePath(stateEnv)); + claimant.exec("BEGIN IMMEDIATE;"); + claimantTransactionOpen = true; + claimant + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)", + ) + .run("gateway.supervision", JSON.stringify(ownership), ownership.claimedAt); + beforeSentinelRow = claimant + .prepare("SELECT * FROM gateway_restart_sentinel WHERE sentinel_key = ?") + .get("current"); + }, + whileHelperRunning: async ({ logPath }) => { + await vi.waitFor( + async () => { + await expect(fs.readFile(logPath, "utf8")).resolves.toContain( + "did not exit before handoff timeout", + ); + }, + { interval: 5, timeout: 2_000 }, + ); + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + if (!claimant) { + throw new Error("expected the ownership claimant transaction to remain open"); + } + claimant.exec("COMMIT;"); + claimantTransactionOpen = false; + claimant.close(); + claimant = undefined; + }, + }); + } finally { + if (claimantTransactionOpen) { + try { + claimant?.exec("ROLLBACK;"); + } catch {} + } + try { + claimant?.close(); + } catch {} + } + + if (!helperResult) { + throw new Error("expected the detached helper to return a result"); + } + expect(helperResult.result).toEqual({ code: 1, signal: null }); + const databasePath = resolveOpenClawStateSqlitePath(helperResult.env); + const sqlite = await import("node:sqlite"); + const verifyDb = new sqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + const ownershipRow = verifyDb + .prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?") + .get("gateway.supervision") as { value_json?: unknown } | undefined; + expect(ownershipRow?.value_json).toBe(JSON.stringify(ownership)); + expect( + verifyDb + .prepare("SELECT * FROM gateway_restart_sentinel WHERE sentinel_key = ?") + .get("current"), + ).toEqual(beforeSentinelRow); + } finally { + verifyDb.close(); + } + await expect(fs.readFile(helperResult.logPath, "utf8")).resolves.toMatch( + /race-supervisor.*OPENCLAW_SUPERVISOR_MODE=external/u, + ); + }); +}); diff --git a/src/infra/update-managed-service-handoff.ts b/src/infra/update-managed-service-handoff.ts index 56c0cd77c301..03a3802a717f 100644 --- a/src/infra/update-managed-service-handoff.ts +++ b/src/infra/update-managed-service-handoff.ts @@ -40,6 +40,7 @@ const { spawn, spawnSync } = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); +const { pathToFileURL } = require("node:url"); const params = JSON.parse(fs.readFileSync(process.argv[2], "utf-8")); @@ -117,15 +118,96 @@ function isPendingUpdatePayload(payload) { ); } +// Keep this self-contained helper aligned with resolveImmutableSqliteFileUri; +// the detached script cannot import the TypeScript runtime after replacement. +function resolveImmutableStateDatabaseUri(databasePath) { + if (process.platform === "win32") { + const namespacedPath = path.toNamespacedPath(path.resolve(databasePath)); + return "file:" + encodeURIComponent(namespacedPath) + "?mode=ro&immutable=1"; + } + return pathToFileURL(path.resolve(databasePath)).href + "?mode=ro&immutable=1"; +} + +function assertStateDatabaseWriteAllowed(database) { + if ( + !params.stateDatabasePath || + typeof params.stateDatabasePath !== "string" || + (!database && !fs.existsSync(params.stateDatabasePath)) + ) { + return; + } + const ownsDatabase = !database; + let db = database; + if (!db) { + const sqlite = require("node:sqlite"); + db = new sqlite.DatabaseSync(resolveImmutableStateDatabaseUri(params.stateDatabasePath), { + readOnly: true, + }); + } + try { + if (ownsDatabase) { + db.exec("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;"); + } + const table = db + .prepare("SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = 'config_machine_state' LIMIT 1") + .get(); + if (!table) return; + const row = db + .prepare("SELECT value_json FROM config_machine_state WHERE state_key = 'gateway.supervision' LIMIT 1") + .get(); + if (!row) return; + let value = null; + if (typeof row.value_json === "string") { + try { + value = JSON.parse(row.value_json); + } catch { + // The shared owner contract below rejects invalid JSON and shape together. + } + } + const keys = value && typeof value === "object" && !Array.isArray(value) + ? Object.keys(value).sort() + : []; + if ( + keys.join(",") !== "claimedAt,managerId,mode,version" || + value.version !== 1 || + value.mode !== "external" || + typeof value.managerId !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.managerId) || + !Number.isSafeInteger(value.claimedAt) || + value.claimedAt < 0 || + value.claimedAt > 8640000000000000 + ) { + throw new Error("shared-state ownership metadata is malformed"); + } + if ((process.env.OPENCLAW_SUPERVISOR_MODE || "").trim().toLowerCase() !== "external") { + throw new Error( + "shared state is externally supervised by " + + value.managerId + + "; use that external supervisor with OPENCLAW_SUPERVISOR_MODE=external", + ); + } + } finally { + if (ownsDatabase) { + db.close(); + } + } +} + function openStateDatabase() { if (!params.stateDatabasePath || typeof params.stateDatabasePath !== "string") { return null; } + let db = null; + let transactionOpen = false; try { + assertStateDatabaseWriteAllowed(); const sqlite = require("node:sqlite"); fs.mkdirSync(path.dirname(params.stateDatabasePath), { recursive: true, mode: 0o700 }); - const db = new sqlite.DatabaseSync(params.nodeSqliteLocation); + db = new sqlite.DatabaseSync(params.nodeSqliteLocation); db.exec("PRAGMA busy_timeout = ${HANDOFF_STATE_DATABASE_BUSY_TIMEOUT_MS};"); + db.exec("BEGIN IMMEDIATE;"); + transactionOpen = true; + assertStateDatabaseWriteAllowed(db); db.exec([ "CREATE TABLE IF NOT EXISTS gateway_restart_sentinel (", "sentinel_key TEXT NOT NULL PRIMARY KEY,", @@ -150,8 +232,18 @@ function openStateDatabase() { ].join(" ")); ensureGatewayRestartSentinelColumns(db); hardenStateDatabaseFiles(); + db.exec("COMMIT;"); + transactionOpen = false; return db; } catch (err) { + if (transactionOpen) { + try { + db.exec("ROLLBACK;"); + } catch {} + } + try { + db?.close(); + } catch {} appendLog("failed to open restart sentinel database: " + (err && err.stack ? err.stack : String(err))); return null; } @@ -389,6 +481,7 @@ function markUpdateSentinelFailureIfPending(reason) { try { db.exec("BEGIN IMMEDIATE;"); transactionOpen = true; + assertStateDatabaseWriteAllowed(db); const current = readRestartSentinelRecord(db); if ( (snapshot === null && current !== null) || @@ -435,7 +528,6 @@ function markUpdateSentinelFailureIfPending(reason) { } appendLog("failed to write update sentinel failure: " + (err && err.stack ? err.stack : String(err))); } finally { - hardenStateDatabaseFiles(); try { db.close(); } catch {} diff --git a/src/proxy-capture/store.sqlite.test.ts b/src/proxy-capture/store.sqlite.test.ts index 486ea164e548..3bcd844e8fe7 100644 --- a/src/proxy-capture/store.sqlite.test.ts +++ b/src/proxy-capture/store.sqlite.test.ts @@ -6,6 +6,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { claimOpenClawStateOwnership } from "../state/openclaw-state-ownership-operations.js"; +import { OpenClawStateOwnershipError } from "../state/openclaw-state-ownership.js"; import { acquireDebugProxyCaptureStore, closeDebugProxyCaptureStore, @@ -77,6 +79,24 @@ describe("DebugProxyCaptureStore", () => { expect(() => rebound.endSession("exit-session")).not.toThrow(); }); + it("fences a shared store that was opened before external ownership was claimed", () => { + const env = makeStateEnv("openclaw-proxy-capture-preclaim-"); + const store = new DebugProxyCaptureStore({ env }); + env.OPENCLAW_SUPERVISOR_MODE = "external"; + claimOpenClawStateOwnership("gateway-supervisor", { env }); + delete env.OPENCLAW_SUPERVISOR_MODE; + + expect(() => + store.upsertSession({ + id: "preclaim-session", + startedAt: 1, + mode: "proxy-run", + sourceScope: "openclaw", + sourceProcess: "cli", + }), + ).toThrow(OpenClawStateOwnershipError); + }); + it("tracks and closes cached stores independently across paths", () => { const first = acquireDebugProxyCaptureStore({ env: makeStateEnv("openclaw-proxy-capture-first-"), @@ -99,6 +119,14 @@ describe("DebugProxyCaptureStore", () => { const dbPath = path.join(root, "capture.sqlite"); const blobDir = path.join(root, "blobs"); const lease = acquireDebugProxyCaptureStore(dbPath, blobDir); + lease.store.db.exec(` + CREATE TABLE config_machine_state ( + state_key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + INSERT INTO config_machine_state VALUES ('gateway.supervision', '{"malformed":true}', 1); + `); expect(getDebugProxyCaptureStore(dbPath, blobDir)).toBe(lease.store); lease.store.upsertSession({ @@ -138,6 +166,7 @@ describe("DebugProxyCaptureStore", () => { .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'capture_blobs'") .get(), ).toBeUndefined(); + lease.store.db.exec("DROP TABLE config_machine_state;"); expect( lease.store.db .prepare( diff --git a/src/proxy-capture/store.sqlite.ts b/src/proxy-capture/store.sqlite.ts index 4f5ecbce5e1c..b37286537f96 100644 --- a/src/proxy-capture/store.sqlite.ts +++ b/src/proxy-capture/store.sqlite.ts @@ -17,7 +17,11 @@ import { registerSqliteCacheExitClose, type SqliteWalMaintenance, } from "../infra/sqlite-wal.js"; -import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, + type OpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; import type { CaptureBlobRecord, CaptureEventRecord, @@ -197,6 +201,24 @@ function sortObservedCounts(counts: Map): CaptureObservedDimensi .toSorted((left, right) => right.count - left.count || left.value.localeCompare(right.value)); } +type SharedDebugProxyCaptureState = { + database: OpenClawStateDatabase; + env?: NodeJS.ProcessEnv; +}; + +const sharedDebugProxyCaptureStates = new WeakMap(); + +function runSharedDebugProxyCaptureWrite(owner: object, operation: () => T): T { + const shared = sharedDebugProxyCaptureStates.get(owner); + if (!shared) { + throw new Error("shared debug proxy capture state is unavailable"); + } + return runOpenClawStateWriteTransaction(() => operation(), { + database: shared.database, + env: shared.env ?? process.env, + }); +} + class DebugProxyCaptureStoreImpl { readonly db: DatabaseSync; readonly dbPath: string; @@ -220,6 +242,7 @@ class DebugProxyCaptureStoreImpl { return; } const database = openOpenClawStateDatabase({ env: optionsOrDbPath.env }); + sharedDebugProxyCaptureStates.set(this, { database, env: optionsOrDbPath.env }); this.db = database.db; this.dbPath = database.path; // Retain the shipped public property while shared-state blobs live in this DB. @@ -269,36 +292,44 @@ class DebugProxyCaptureStoreImpl { ); return; } - this.db - .prepare( - `INSERT INTO capture_sessions ( - id, started_at, ended_at, mode, source_scope, source_process, proxy_url - ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - started_at=MIN(capture_sessions.started_at, excluded.started_at), - ended_at=excluded.ended_at, - mode=CASE - WHEN capture_sessions.mode = 'implicit' THEN excluded.mode - ELSE capture_sessions.mode - END, - proxy_url=excluded.proxy_url, - source_process=excluded.source_process`, - ) - .run( - session.id, - session.startedAt, - session.endedAt ?? null, - session.mode, - session.sourceScope, - session.sourceProcess, - session.proxyUrl ?? null, - ); + runSharedDebugProxyCaptureWrite(this, () => + this.db + .prepare( + `INSERT INTO capture_sessions ( + id, started_at, ended_at, mode, source_scope, source_process, proxy_url + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + started_at=MIN(capture_sessions.started_at, excluded.started_at), + ended_at=excluded.ended_at, + mode=CASE + WHEN capture_sessions.mode = 'implicit' THEN excluded.mode + ELSE capture_sessions.mode + END, + proxy_url=excluded.proxy_url, + source_process=excluded.source_process`, + ) + .run( + session.id, + session.startedAt, + session.endedAt ?? null, + session.mode, + session.sourceScope, + session.sourceProcess, + session.proxyUrl ?? null, + ), + ); } endSession(sessionId: string, endedAt = Date.now()): void { - this.db - .prepare(`UPDATE capture_sessions SET ended_at = ? WHERE id = ?`) - .run(endedAt, sessionId); + const update = () => + this.db + .prepare(`UPDATE capture_sessions SET ended_at = ? WHERE id = ?`) + .run(endedAt, sessionId); + if (this.pathBased) { + update(); + return; + } + runSharedDebugProxyCaptureWrite(this, update); } persistPayload(data: Buffer, contentType?: string): CaptureBlobRecord | SharedCaptureBlobRecord { @@ -325,21 +356,23 @@ class DebugProxyCaptureStoreImpl { ...(contentType ? { contentType } : {}), }; } - this.db - .prepare( - `INSERT OR IGNORE INTO capture_blobs ( - blob_id, content_type, encoding, size_bytes, sha256, data, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - blobId, - contentType ?? null, - "gzip", - data.byteLength, - sha256, - gzipSync(data), - Date.now(), - ); + runSharedDebugProxyCaptureWrite(this, () => + this.db + .prepare( + `INSERT OR IGNORE INTO capture_blobs ( + blob_id, content_type, encoding, size_bytes, sha256, data, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + blobId, + contentType ?? null, + "gzip", + data.byteLength, + sha256, + gzipSync(data), + Date.now(), + ), + ); return { blobId, encoding: "gzip", @@ -354,7 +387,7 @@ class DebugProxyCaptureStoreImpl { this.insertEvent(event, event.dataBlobId ?? null); return; } - runSqliteImmediateTransactionSync(this.db, () => { + runSharedDebugProxyCaptureWrite(this, () => { // Capture can be invoked directly by provider seams before the top-level // runtime initializes. Keep the shared-schema foreign key valid without // making diagnostics break the request they are observing. @@ -628,7 +661,7 @@ class DebugProxyCaptureStoreImpl { } return { sessions: sessionCount, events: eventCount, blobs }; } - return runSqliteImmediateTransactionSync(this.db, () => { + return runSharedDebugProxyCaptureWrite(this, () => { const sessionCount = ( this.db.prepare(`SELECT COUNT(*) AS count FROM capture_sessions`).get() as { @@ -656,7 +689,7 @@ class DebugProxyCaptureStoreImpl { if (this.pathBased) { return this.deletePathBasedSessions(uniqueSessionIds); } - return runSqliteImmediateTransactionSync(this.db, () => { + return runSharedDebugProxyCaptureWrite(this, () => { const placeholders = uniqueSessionIds.map(() => "?").join(", "); const blobRows = this.db .prepare( diff --git a/src/state/openclaw-database-maintenance.test.ts b/src/state/openclaw-database-maintenance.test.ts index b06233a20f9c..47e187efd0d0 100644 --- a/src/state/openclaw-database-maintenance.test.ts +++ b/src/state/openclaw-database-maintenance.test.ts @@ -8,12 +8,12 @@ import { } from "./openclaw-agent-db.js"; import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.js"; import { CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS } from "./openclaw-state-db-additive-columns.js"; -import { CLAW_LAZY_ADDITIVE_STATE_COLUMNS } from "./openclaw-state-db-maintenance.js"; import { ensureAdditiveStateColumns } from "./openclaw-state-db-schema-additive.js"; import { assertOpenClawStateDatabaseForMaintenance, OPENCLAW_STATE_SCHEMA_VERSION, } from "./openclaw-state-db.js"; +import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "./openclaw-state-schema-compatibility.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; describe("OpenClaw database maintenance schema validation", () => { @@ -129,7 +129,7 @@ describe("OpenClaw database maintenance schema validation", () => { CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map( ({ columnName, tableName }) => `${tableName}.${columnName}`, ), - ).toEqual(CLAW_LAZY_ADDITIVE_STATE_COLUMNS); + ).toEqual(OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns); expect( CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map( ({ columnName, dataType, tableName }) => `${tableName}.${columnName} ${dataType}`, diff --git a/src/state/openclaw-database-preflight.test.ts b/src/state/openclaw-database-preflight.test.ts index d3219ec22d7b..cbd3adf407d8 100644 --- a/src/state/openclaw-database-preflight.test.ts +++ b/src/state/openclaw-database-preflight.test.ts @@ -1,7 +1,10 @@ +import fs from "node:fs"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import packageJson from "../../package.json" with { type: "json" }; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; +import { collectSqliteSchemaIssues } from "../infra/sqlite-schema-contract.js"; import { closeOpenClawAgentDatabasesForTest, OPENCLAW_AGENT_SCHEMA_VERSION, @@ -9,6 +12,7 @@ import { } from "./openclaw-agent-db.js"; import { assertOpenClawDatabasesReadyForRestart, + preflightOpenClawStateDatabasePath, preflightOpenClawDatabaseSchemas, } from "./openclaw-database-preflight.js"; import { @@ -16,6 +20,7 @@ import { OPENCLAW_STATE_SCHEMA_VERSION, openOpenClawStateDatabase, } from "./openclaw-state-db.js"; +import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -25,6 +30,283 @@ afterEach(() => { }); describe("OpenClaw database schema preflight", () => { + function snapshotSourceFamily(databasePath: string) { + const paths = [databasePath, `${databasePath}-wal`, `${databasePath}-shm`].filter( + fs.existsSync, + ); + return { + entries: fs.readdirSync(path.dirname(databasePath)).toSorted(), + files: paths.map((pathname) => { + const stat = fs.statSync(pathname, { bigint: true }); + return { + pathname, + bytes: fs.readFileSync(pathname), + birthtimeNs: stat.birthtimeNs, + ctimeNs: stat.ctimeNs, + dev: stat.dev, + ino: stat.ino, + mtimeNs: stat.mtimeNs, + size: stat.size, + }; + }), + }; + } + + function createExplicitStateDatabase(schemaSql = OPENCLAW_STATE_SCHEMA_SQL): string { + const stateDir = tempDirs.make("openclaw-explicit-state-preflight-"); + const databasePath = path.join(stateDir, "candidate.sqlite"); + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(databasePath); + try { + database.exec(`${schemaSql}; PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`); + database + .prepare( + `INSERT INTO schema_meta ( + meta_key, role, schema_version, agent_id, app_version, created_at, updated_at + ) VALUES ('primary', 'global', ?, NULL, NULL, 1, 1)`, + ) + .run(OPENCLAW_STATE_SCHEMA_VERSION); + } finally { + database.close(); + } + return databasePath; + } + + it("reports an exact current schema for one explicit copied database", async () => { + const stateDir = tempDirs.make("openclaw-runtime-state-preflight-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const opened = openOpenClawStateDatabase({ env }); + const databasePath = opened.path; + expect( + opened.db + .prepare( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'execution_identity_contexts'", + ) + .get(), + ).toBeUndefined(); + closeOpenClawStateDatabaseForTest(); + + await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({ + schema: "openclaw.state-schema-preflight.v1", + databasePath, + targetVersion: OPENCLAW_STATE_SCHEMA_VERSION, + foundVersion: OPENCLAW_STATE_SCHEMA_VERSION, + ownership: null, + issues: [], + status: "exact", + requiresWrite: false, + }); + }); + + it("treats a supported persistent column definition as exact", async () => { + const databasePath = createExplicitStateDatabase( + OPENCLAW_STATE_SCHEMA_SQL.replace( + " kind TEXT NOT NULL,\n sensitivity TEXT NOT NULL,", + " kind TEXT NOT NULL DEFAULT 'followup',\n sensitivity TEXT NOT NULL,", + ), + ); + + await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({ + schema: "openclaw.state-schema-preflight.v1", + databasePath, + targetVersion: OPENCLAW_STATE_SCHEMA_VERSION, + foundVersion: OPENCLAW_STATE_SCHEMA_VERSION, + ownership: null, + status: "exact", + requiresWrite: false, + issues: [], + }); + }); + + it("accepts a copied current schema with a future bare nullable column without touching it", async () => { + const sourcePath = createExplicitStateDatabase(); + const databasePath = path.join( + tempDirs.make("openclaw-copied-state-preflight-"), + "candidate.sqlite", + ); + fs.copyFileSync(sourcePath, databasePath); + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(databasePath); + try { + database.exec("ALTER TABLE worktrees ADD COLUMN future_note TEXT;"); + } finally { + database.close(); + } + const before = snapshotSourceFamily(databasePath); + + await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({ + schema: "openclaw.state-schema-preflight.v1", + databasePath, + targetVersion: OPENCLAW_STATE_SCHEMA_VERSION, + foundVersion: OPENCLAW_STATE_SCHEMA_VERSION, + ownership: null, + status: "exact", + requiresWrite: false, + issues: [], + }); + expect(snapshotSourceFamily(databasePath)).toEqual(before); + }); + + it("classifies a drifted canonical named index as startup-repairable", async () => { + const databasePath = createExplicitStateDatabase(); + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(databasePath); + try { + database.exec(` + DROP INDEX idx_task_runs_status; + CREATE INDEX idx_task_runs_status ON task_runs(task_id); + `); + } finally { + database.close(); + } + + await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toEqual({ + schema: "openclaw.state-schema-preflight.v1", + databasePath, + targetVersion: OPENCLAW_STATE_SCHEMA_VERSION, + foundVersion: OPENCLAW_STATE_SCHEMA_VERSION, + ownership: null, + status: "startup-repairable", + requiresWrite: true, + issues: [ + { + code: "missing-or-drifted-index", + message: "missing or drifted index idx_task_runs_status", + objectName: "idx_task_runs_status", + }, + ], + }); + }); + + it("classifies the same-version run-end cleanup column as startup-repairable without touching the source", async () => { + const sourcePath = createExplicitStateDatabase( + OPENCLAW_STATE_SCHEMA_SQL.replace( + " removed_at INTEGER,\n run_end_cleanup_json TEXT\n", + " removed_at INTEGER\n", + ), + ); + const snapshotPath = path.join( + tempDirs.make("openclaw-consolidated-state-preflight-"), + "candidate.sqlite", + ); + const sqlite = requireNodeSqlite(); + const writer = new sqlite.DatabaseSync(sourcePath); + try { + writer.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;"); + writer + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES ('preflight.probe', '{}', 1)", + ) + .run(); + await sqlite.backup(writer, snapshotPath); + writer + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES ('preflight.after-backup', '{}', 2)", + ) + .run(); + expect(fs.existsSync(`${sourcePath}-wal`)).toBe(true); + expect(fs.existsSync(`${sourcePath}-shm`)).toBe(true); + for (const suffix of ["-wal", "-shm", "-journal"]) { + expect(fs.existsSync(`${snapshotPath}${suffix}`)).toBe(false); + } + const before = snapshotSourceFamily(sourcePath); + + const result = await preflightOpenClawStateDatabasePath(snapshotPath); + + expect(result).toMatchObject({ + foundVersion: OPENCLAW_STATE_SCHEMA_VERSION, + status: "startup-repairable", + requiresWrite: true, + issues: [ + { + code: "missing-column", + objectName: "worktrees.run_end_cleanup_json", + }, + ], + }); + expect(snapshotSourceFamily(sourcePath)).toEqual(before); + } finally { + writer.close(); + } + }); + + it("rejects an explicit preflight path with sidecars without touching it", async () => { + const databasePath = createExplicitStateDatabase(); + const sqlite = requireNodeSqlite(); + const writer = new sqlite.DatabaseSync(databasePath); + try { + writer.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;"); + writer + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES ('preflight.live', '{}', 1)", + ) + .run(); + const before = snapshotSourceFamily(databasePath); + + await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({ + foundVersion: null, + status: "indeterminate", + requiresWrite: false, + reason: expect.stringMatching(/consolidated snapshot.*sidecars.*online backup/iu), + }); + expect(snapshotSourceFamily(databasePath)).toEqual(before); + } finally { + writer.close(); + } + }); + + it("reports an explicit unreadable path as indeterminate", async () => { + const stateDir = tempDirs.make("openclaw-explicit-unreadable-preflight-"); + const databasePath = path.join(stateDir, "not-sqlite.db"); + fs.writeFileSync(databasePath, "not a sqlite database"); + + await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({ + databasePath, + foundVersion: null, + status: "indeterminate", + requiresWrite: false, + reason: expect.stringMatching(/database|file/iu), + }); + }); + + it("reports invalid negative schema metadata as indeterminate", async () => { + const databasePath = createExplicitStateDatabase(); + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(databasePath); + try { + database.exec("PRAGMA user_version = -1;"); + } finally { + database.close(); + } + + await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({ + foundVersion: -1, + status: "indeterminate", + reason: expect.stringContaining("invalid schema version metadata"), + }); + }); + + it("treats a current-v6 additive column as incompatible with the older v6 shape", () => { + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(":memory:"); + try { + database.exec(OPENCLAW_STATE_SCHEMA_SQL); + const olderV6Schema = OPENCLAW_STATE_SCHEMA_SQL.replace( + " removed_at INTEGER,\n run_end_cleanup_json TEXT\n", + " removed_at INTEGER\n", + ); + + expect(collectSqliteSchemaIssues(database, olderV6Schema)).toContainEqual( + expect.objectContaining({ + code: "unexpected-column", + objectName: "worktrees.run_end_cleanup_json", + }), + ); + } finally { + database.close(); + } + }); + it("keeps package schema support metadata aligned", () => { expect(packageJson.openclaw.schemaVersions).toEqual({ state: OPENCLAW_STATE_SCHEMA_VERSION, @@ -268,4 +550,3 @@ describe("OpenClaw database schema preflight", () => { }); }); }); -import fs from "node:fs"; diff --git a/src/state/openclaw-database-preflight.ts b/src/state/openclaw-database-preflight.ts index bff421417edc..35c82f208643 100644 --- a/src/state/openclaw-database-preflight.ts +++ b/src/state/openclaw-database-preflight.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { formatErrorMessage } from "../infra/errors.js"; @@ -7,7 +7,12 @@ import { executeSqliteQuerySync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; -import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; +import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js"; +import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js"; +import { + collectSqliteSchemaIssues, + type SqliteSchemaIssue, +} from "../infra/sqlite-schema-contract.js"; import { describeRunningOpenClawBuild, readSqliteUserVersion, @@ -20,9 +25,23 @@ import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, OPENCLAW_STATE_SCHEMA_VERSION, } from "./openclaw-state-db-contract.js"; -import { assertOpenClawStateDatabaseForMaintenance } from "./openclaw-state-db-maintenance.js"; +import { + assertOpenClawStateDatabaseOwner, + assertOpenClawStateDatabaseForMaintenance, +} from "./openclaw-state-db-maintenance.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; +import { + inspectOpenClawStateOwnershipFromDatabase, + type OpenClawExternalStateOwnership, +} from "./openclaw-state-ownership.js"; +import { + getOpenClawStateRuntimeSchema, + isOpenClawStateStartupRepairableSchemaIssue, + OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY, + STATE_PERSISTENT_SCHEMA_COMPATIBILITY, +} from "./openclaw-state-schema-compatibility.js"; +import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; export { OPENCLAW_DATABASE_SCHEMA_DOCS_URL } from "./openclaw-state-db.js"; @@ -46,6 +65,18 @@ export type OpenClawDatabaseSchemaPreflight = { indeterminate: IndeterminateOpenClawDatabase[]; }; +type OpenClawStateSchemaPreflightResult = { + databasePath: string; + foundVersion: number | null; + issues: SqliteSchemaIssue[]; + ownership: OpenClawExternalStateOwnership | null; + reason?: string; + requiresWrite: boolean; + schema: "openclaw.state-schema-preflight.v1"; + status: "exact" | "startup-repairable" | "migration-required" | "incompatible" | "indeterminate"; + targetVersion: number; +}; + type AgentRegistryDatabase = Pick; type OpenClawDatabaseSchemaPreflightOperation = "doctor" | "gateway-restart" | "gateway-startup"; @@ -142,6 +173,122 @@ function readRegisteredAgentDatabases(database: DatabaseSync): Array<{ ); } +function deduplicateSchemaIssues(issues: readonly SqliteSchemaIssue[]): SqliteSchemaIssue[] { + return [ + ...new Map( + issues.map((issue) => [`${issue.code}\0${issue.objectName}`, issue] as const), + ).values(), + ]; +} + +/** Compare one explicit SQLite file with this release's canonical shared-state schema. */ +export async function preflightOpenClawStateDatabasePath( + databasePath: string, +): Promise { + const resolvedPath = path.resolve(databasePath); + const base = { + schema: "openclaw.state-schema-preflight.v1", + databasePath: resolvedPath, + targetVersion: OPENCLAW_STATE_SCHEMA_VERSION, + } as const; + let database: DatabaseSync | undefined; + let foundVersion: number | null = null; + let ownership: OpenClawExternalStateOwnership | null = null; + const result = ( + status: OpenClawStateSchemaPreflightResult["status"], + details: { issues?: SqliteSchemaIssue[]; reason?: string; requiresWrite?: boolean } = {}, + ): OpenClawStateSchemaPreflightResult => ({ + ...base, + foundVersion, + ownership, + issues: details.issues ?? [], + status, + requiresWrite: details.requiresWrite ?? false, + ...(details.reason ? { reason: details.reason } : {}), + }); + try { + const inspectionPath = realpathSync.native(resolvedPath); + const sidecars = ["-wal", "-shm", "-journal"].filter((suffix) => + existsSync(`${inspectionPath}${suffix}`), + ); + if (sidecars.length > 0) { + throw new Error( + `SQLite preflight requires a consolidated snapshot with no sidecars; found ${sidecars.join(", ")}. Create a WAL-aware online backup and preflight the resulting standalone file.`, + ); + } + database = openNodeSqliteDatabase(resolveImmutableSqliteFileUri(inspectionPath), { + readOnly: true, + }); + database.exec( + `PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS}; PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;`, + ); + assertSqliteIntegrity(database, resolvedPath); + foundVersion = readSqliteUserVersion(database); + if (!Number.isSafeInteger(foundVersion) || foundVersion < 0) { + throw new Error( + `OpenClaw state database ${resolvedPath} has invalid schema version metadata.`, + ); + } + if (foundVersion > OPENCLAW_STATE_SCHEMA_VERSION) { + try { + ownership = inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath); + } catch { + // A newer release can own a newer metadata contract; the numeric refusal remains decisive. + } + return result("incompatible"); + } + ownership = inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath); + if (foundVersion < OPENCLAW_STATE_SCHEMA_VERSION) { + return result("migration-required", { requiresWrite: true }); + } + assertOpenClawStateDatabaseOwner(database, { pathname: resolvedPath }); + const metadata = database + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1") + .get() as { schema_version?: unknown } | undefined; + if (metadata?.schema_version !== foundVersion) { + throw new Error( + `OpenClaw state database ${resolvedPath} metadata schema version ${typeof metadata?.schema_version === "number" ? metadata.schema_version : "invalid"} does not match ${foundVersion}.`, + ); + } + const maintenanceIssues = collectSqliteSchemaIssues( + database, + OPENCLAW_STATE_SCHEMA_SQL, + OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY, + ); + const blockingIssues = maintenanceIssues.filter( + (issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue), + ); + if (blockingIssues.length > 0) { + return result("incompatible", { issues: deduplicateSchemaIssues(blockingIssues) }); + } + const projectedRuntimeIssues = collectSqliteSchemaIssues( + database, + getOpenClawStateRuntimeSchema({ includeVersionLazyAdditiveTables: false }), + STATE_PERSISTENT_SCHEMA_COMPATIBILITY, + ); + const projectedRuntimeBlockingIssues = projectedRuntimeIssues.filter( + (issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue), + ); + if (projectedRuntimeBlockingIssues.length > 0) { + return result("incompatible", { + issues: deduplicateSchemaIssues(projectedRuntimeBlockingIssues), + }); + } + const startupRepairableIssues = deduplicateSchemaIssues([ + ...maintenanceIssues, + ...projectedRuntimeIssues, + ]); + return result(startupRepairableIssues.length > 0 ? "startup-repairable" : "exact", { + issues: startupRepairableIssues, + requiresWrite: startupRepairableIssues.length > 0, + }); + } catch (error) { + return result("indeterminate", { reason: formatErrorMessage(error) }); + } finally { + database?.close(); + } +} + /** Read schema headers and optionally verify current schema shape without repairing it. */ export function preflightOpenClawDatabaseSchemas(options: { env: NodeJS.ProcessEnv; diff --git a/src/state/openclaw-state-db-maintenance.ts b/src/state/openclaw-state-db-maintenance.ts index 8d060190814b..45238b4e8c10 100644 --- a/src/state/openclaw-state-db-maintenance.ts +++ b/src/state/openclaw-state-db-maintenance.ts @@ -3,7 +3,6 @@ import type { DatabaseSync } from "node:sqlite"; import { assertSqliteSchemaContains, assertSqliteSchemaTablesPresent, - type SqliteSchemaCompatibility, } from "../infra/sqlite-schema-contract.js"; import { createNewerSqliteSchemaVersionError, @@ -16,66 +15,9 @@ import { type OpenClawStateDatabaseOptions, } from "./openclaw-state-db-contract.js"; import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; +import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "./openclaw-state-schema-compatibility.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; -/** - * Additive Claw provenance and worker-environment columns that only a writable - * open can ensure. A same-version database written before them stays readable - * so read-only planning surfaces are not refused before they can report anything. - */ -export const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [ - "claw_installs.bootstrap_content_digest", - "claw_installs.bootstrap_source_path", - "worker_environments.desktop_json", - "claw_package_refs.extension_adapter_identity", - "claw_package_refs.extension_detected_format", - "claw_package_refs.extension_format", - "claw_package_refs.extension_id", - "claw_package_refs.extension_mapped_json", - "claw_package_refs.extension_unavailable_json", - "worker_environments.shared_host", - "worktrees.run_end_cleanup_json", -] as const; - -const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = { - allowCompatibleAdditiveColumns: true, - allowedMissingTables: LAZY_ADDITIVE_STATE_TABLES, - allowedMissingColumns: CLAW_LAZY_ADDITIVE_STATE_COLUMNS, - allowedColumnDefinitions: { - "diagnostic_events.sequence": ["sequence INTEGER NOT NULL DEFAULT 0"], - "commitments.attempts": ["attempts INTEGER NOT NULL DEFAULT 0"], - "commitments.confidence": ["confidence REAL NOT NULL DEFAULT 0"], - "commitments.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"], - "commitments.dedupe_key": ["dedupe_key TEXT NOT NULL DEFAULT ''"], - "commitments.due_timezone": ["due_timezone TEXT NOT NULL DEFAULT 'UTC'"], - "commitments.kind": ["kind TEXT NOT NULL DEFAULT 'followup'"], - "commitments.reason": ["reason TEXT NOT NULL DEFAULT ''"], - "commitments.sensitivity": ["sensitivity TEXT NOT NULL DEFAULT 'normal'"], - "commitments.source": ["source TEXT NOT NULL DEFAULT 'unknown'"], - "commitments.suggested_text": ["suggested_text TEXT NOT NULL DEFAULT ''"], - "claw_package_refs.package_integrity": [ - "package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'", - ], - "claw_package_refs.updated_at_ms": ["updated_at_ms INTEGER NOT NULL DEFAULT 0"], - "cron_jobs.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"], - "cron_jobs.enabled": ["enabled INTEGER NOT NULL DEFAULT 1"], - "cron_jobs.name": ["name TEXT NOT NULL DEFAULT ''"], - "cron_jobs.payload_kind": ["payload_kind TEXT NOT NULL DEFAULT 'message'"], - "cron_jobs.schedule_kind": ["schedule_kind TEXT NOT NULL DEFAULT 'manual'"], - "cron_jobs.session_target": ["session_target TEXT NOT NULL DEFAULT 'main'"], - "cron_jobs.wake_mode": ["wake_mode TEXT NOT NULL DEFAULT 'auto'"], - "current_conversation_bindings.conversation_kind": [ - "conversation_kind TEXT NOT NULL DEFAULT 'channel'", - ], - "current_conversation_bindings.target_agent_id": [ - "target_agent_id TEXT NOT NULL DEFAULT 'main'", - ], - "operator_approvals.resolution_ref": ["resolution_ref TEXT"], - "worker_environments.desktop_json": ["desktop_json TEXT"], - "worker_environments.shared_host": ["shared_host INTEGER CHECK (shared_host IN (0, 1))"], - }, -} satisfies SqliteSchemaCompatibility; - const STATE_V5_ADDITIVE_TABLES = [ "agent_database_leases", "agent_deletion_journal", @@ -148,7 +90,7 @@ export function assertOpenClawStateDatabaseOwner( /** Require the canonical shared-state owner and schema before offline file maintenance. */ export function assertOpenClawStateDatabaseForMaintenance( database: DatabaseSync, - options: { pathname: string; allowedMissingColumns?: readonly string[] }, + options: { pathname: string }, ): void { const userVersion = readSqliteUserVersion(database); if (userVersion > OPENCLAW_STATE_SCHEMA_VERSION) { @@ -180,12 +122,7 @@ export function assertOpenClawStateDatabaseForMaintenance( database, options.pathname, OPENCLAW_STATE_SCHEMA_SQL, - options.allowedMissingColumns - ? { - ...OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY, - allowedMissingColumns: options.allowedMissingColumns, - } - : OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY, + OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY, ); } diff --git a/src/state/openclaw-state-db-startup-checkpoint.ts b/src/state/openclaw-state-db-startup-checkpoint.ts index e52afaf59c44..07ad3597cffc 100644 --- a/src/state/openclaw-state-db-startup-checkpoint.ts +++ b/src/state/openclaw-state-db-startup-checkpoint.ts @@ -13,11 +13,17 @@ import { } from "./openclaw-state-db-maintenance.js"; import { ensureOpenClawStatePermissions } from "./openclaw-state-db-permissions.js"; import { ensureColumn } from "./openclaw-state-db-schema-helpers.js"; +import { assertOpenClawStateWriteAllowed } from "./openclaw-state-ownership.js"; -function ensureStartupMigrationCheckpointSchema(db: DatabaseSync, pathname: string): void { +function ensureStartupMigrationCheckpointSchema( + db: DatabaseSync, + pathname: string, + env: NodeJS.ProcessEnv, +): void { runSqliteImmediateTransactionSync( db, () => { + assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env }); assertSupportedSchemaVersion(db, pathname); db.exec(` CREATE TABLE IF NOT EXISTS schema_meta ( @@ -62,6 +68,7 @@ export function withOpenClawStateStartupMigrationCheckpointDatabase( ): T { const env = options.env ?? process.env; const pathname = resolveDatabasePath(options); + assertOpenClawStateWriteAllowed({ databasePath: pathname, env }); ensureOpenClawStatePermissions(pathname, env); const db = openNodeSqliteDatabase(pathname); try { @@ -69,7 +76,7 @@ export function withOpenClawStateStartupMigrationCheckpointDatabase( busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, }); assertSqliteIntegrity(db, pathname); - ensureStartupMigrationCheckpointSchema(db, pathname); + ensureStartupMigrationCheckpointSchema(db, pathname, env); return callback(db); } finally { db.close(); diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 58f53d3980b9..d5fd414de11d 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -45,10 +45,7 @@ import { } from "./openclaw-quarantine-store.js"; import { repairAuditEventsSchema } from "./openclaw-state-db-audit-migration.js"; import { - FIRST_USE_STATE_INDEXES, - FIRST_USE_STATE_TABLES, OPENCLAW_DATABASE_SCHEMA_DOCS_URL, - LAZY_ADDITIVE_STATE_INDEXES, LAZY_ADDITIVE_STATE_TABLES, OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, OPENCLAW_STATE_SCHEMA_VERSION, @@ -60,7 +57,6 @@ import { assertOpenClawStateDatabaseForMaintenance, assertOpenClawStateDatabaseV5ForMigration, assertSupportedSchemaVersion, - CLAW_LAZY_ADDITIVE_STATE_COLUMNS, createOpenClawDatabaseVerificationError, resolveDatabasePath, } from "./openclaw-state-db-maintenance.js"; @@ -78,6 +74,11 @@ import { } from "./openclaw-state-db-schema-repair.js"; import * as sessionWatchMigration from "./openclaw-state-db-session-watch-migration.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; +import { + assertOpenClawStateWriteAllowed, + OpenClawStateOwnershipError, +} from "./openclaw-state-ownership.js"; +import { getOpenClawStateRuntimeSchema } from "./openclaw-state-schema-compatibility.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; export { @@ -85,7 +86,6 @@ export { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, OPENCLAW_STATE_SCHEMA_VERSION, }; -export const STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS = CLAW_LAZY_ADDITIVE_STATE_COLUMNS; export type { OpenClawStateDatabase, OpenClawStateDatabaseOptions, @@ -201,45 +201,11 @@ export function assertOpenClawStateDatabaseFreshOpenAllowed( type OpenClawStateMetadataDatabase = Pick; const stateDbLog = createSubsystemLogger("state/db"); -function canonicalStateSchemaForRuntime(options: { - includeVersionLazyAdditiveTables: boolean; -}): string { - // Current-version databases may lack lazy additive tables. First-use tables - // remain absent on every schema path so only their feature owner can create them. - let eagerSchema = OPENCLAW_STATE_SCHEMA_SQL; - const omittedTables = options.includeVersionLazyAdditiveTables - ? FIRST_USE_STATE_TABLES - : LAZY_ADDITIVE_STATE_TABLES; - const omittedIndexes = options.includeVersionLazyAdditiveTables - ? FIRST_USE_STATE_INDEXES - : LAZY_ADDITIVE_STATE_INDEXES; - for (const tableName of omittedTables) { - const startMarker = `CREATE TABLE IF NOT EXISTS ${tableName} (`; - const start = eagerSchema.indexOf(startMarker); - const endMarker = "\n) STRICT;"; - const end = start >= 0 ? eagerSchema.indexOf(endMarker, start) : -1; - if (start < 0 || end < 0) { - throw new Error(`lazy additive state schema block is missing for ${tableName}`); - } - eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + endMarker.length)}`; - } - for (const indexName of omittedIndexes) { - const startMarker = `CREATE INDEX IF NOT EXISTS ${indexName}`; - const start = eagerSchema.indexOf(startMarker); - const end = start >= 0 ? eagerSchema.indexOf(";", start) : -1; - if (start < 0 || end < 0) { - throw new Error(`lazy additive state schema index is missing for ${indexName}`); - } - eagerSchema = `${eagerSchema.slice(0, start)}${eagerSchema.slice(end + 1)}`; - } - return eagerSchema; -} - function executeCanonicalStateSchema( database: DatabaseSync, options: { includeVersionLazyAdditiveTables: boolean }, ): void { - database.exec(canonicalStateSchemaForRuntime(options)); + database.exec(getOpenClawStateRuntimeSchema(options)); } export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabaseOptions = {}): { @@ -251,9 +217,11 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase if (!existsSync(pathname)) { return { changes: [], warnings: [] }; } + assertOpenClawStateWriteAllowed({ databasePath: pathname, env }); ensureOpenClawStatePermissions(pathname, env); const db = openNodeSqliteDatabase(pathname); const rebuiltIndexNames = new Set(); + let ownershipRefused = false; try { db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); assertSupportedSchemaVersion(db, pathname); @@ -261,6 +229,7 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase const changes = runSqliteImmediateTransactionSync( db, () => { + assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env }); const applied: string[] = []; const previousVersion = readSqliteUserVersion(db); if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) { @@ -307,7 +276,7 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase } const strictMigration = migrateSqliteSchemaToStrictInTransaction( db, - canonicalStateSchemaForRuntime({ + getOpenClawStateRuntimeSchema({ includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, }), { databaseLabel: pathname }, @@ -351,6 +320,10 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase ], }; } catch (err) { + if (err instanceof OpenClawStateOwnershipError) { + ownershipRefused = true; + throw err; + } // Reaching this catch inside doctor means repair itself refused or failed, // so the runtime asserts' "run openclaw doctor --fix" advice is circular here. const reason = String(err).replace( @@ -367,7 +340,9 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase } clearNodeSqliteKyselyCacheForDatabase(db); db.close(); - ensureOpenClawStatePermissions(pathname, env); + if (!ownershipRefused) { + ensureOpenClawStatePermissions(pathname, env); + } } } @@ -378,10 +353,12 @@ export function repairOpenClawStateDatabaseSchemaIfNeeded( changes: string[]; warnings: string[]; } { + const env = options.env ?? process.env; const pathname = resolveDatabasePath(options); if (!existsSync(pathname)) { return { changes: [], warnings: [] }; } + assertOpenClawStateWriteAllowed({ databasePath: pathname, env }); let needsRepair = true; let database: DatabaseSync | undefined; @@ -406,7 +383,7 @@ export function repairOpenClawStateDatabaseSchemaIfNeeded( return needsRepair ? repairOpenClawStateDatabaseSchema(options) : { changes: [], warnings: [] }; } -function ensureSchema(db: DatabaseSync, pathname: string): void { +function ensureSchema(db: DatabaseSync, pathname: string, env: NodeJS.ProcessEnv): void { const now = Date.now(); const kysely = getNodeSqliteKysely(db); // Rebuilding referenced tables requires disabling FK enforcement before BEGIN. @@ -415,11 +392,15 @@ function ensureSchema(db: DatabaseSync, pathname: string): void { runSqliteImmediateTransactionSync( db, () => { + // Recheck ownership after BEGIN IMMEDIATE so no current-schema repair + // can race a durable external ownership claim. + assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env }); assertSupportedSchemaVersion(db, pathname); const previousVersion = readSqliteUserVersion(db); if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) { - repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { - verifyPhysicalIntegrity: false, + verifyAndRepairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { + allowMissingColumns: true, + validateAfterRepair: () => assertCurrentStateRuntimeSchema(db, pathname), }); ensureAdditiveStateColumns(db); assertCurrentStateRuntimeSchema(db, pathname); @@ -438,7 +419,7 @@ function ensureSchema(db: DatabaseSync, pathname: string): void { repairLegacyGatewayRestartHandoffsForStrictMigration(db); migrateSqliteSchemaToStrictInTransaction( db, - canonicalStateSchemaForRuntime({ + getOpenClawStateRuntimeSchema({ includeVersionLazyAdditiveTables: previousVersion !== OPENCLAW_STATE_SCHEMA_VERSION, }), { databaseLabel: pathname }, @@ -508,10 +489,7 @@ export async function openExistingOpenClawStateDatabaseReadOnly( assertSupportedSchemaVersion(db, pathname); assertSqliteIntegrity(db, pathname); if (readSqliteUserVersion(db) === OPENCLAW_STATE_SCHEMA_VERSION) { - assertOpenClawStateDatabaseForMaintenance(db, { - pathname, - allowedMissingColumns: STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS, - }); + assertOpenClawStateDatabaseForMaintenance(db, { pathname }); } } catch (error) { try { @@ -550,18 +528,9 @@ export async function openExistingOpenClawStateDatabaseReadOnly( }; } -function assertCurrentStateRuntimeSchema( - database: DatabaseSync, - pathname: string, - options: { allowedMissingColumns?: readonly string[] } = {}, -): void { +function assertCurrentStateRuntimeSchema(database: DatabaseSync, pathname: string): void { assertCanonicalStateSchemaShape(database, pathname); - assertOpenClawStateDatabaseForMaintenance(database, { - pathname, - ...(options.allowedMissingColumns - ? { allowedMissingColumns: options.allowedMissingColumns } - : {}), - }); + assertOpenClawStateDatabaseForMaintenance(database, { pathname }); } function assertStateDatabaseIntegrityBeforeMutation( @@ -583,22 +552,10 @@ function assertStateDatabaseIntegrityBeforeMutation( toVersion: OPENCLAW_STATE_SCHEMA_VERSION, }); } - if (userVersion === OPENCLAW_STATE_SCHEMA_VERSION) { - verifyAndRepairCanonicalSqliteIndexes(database, pathname, OPENCLAW_STATE_SCHEMA_SQL, { - allowMissingColumns: true, - validateAfterRepair: () => - assertCurrentStateRuntimeSchema(database, pathname, { - allowedMissingColumns: STATE_READ_ONLY_COMPATIBLE_MISSING_COLUMNS, - }), - }); - ensureAdditiveStateColumns(database); - } else { + if (userVersion !== OPENCLAW_STATE_SCHEMA_VERSION) { // Every physical open proves the full file before schema mutation or exposure. assertSqliteIntegrity(database, pathname); } - if (userVersion === OPENCLAW_STATE_SCHEMA_VERSION) { - assertCurrentStateRuntimeSchema(database, pathname); - } } /** Open or return a cached shared state database after schema and migration checks. */ @@ -606,16 +563,22 @@ function assertStateDatabaseIntegrityBeforeMutation( export function openOpenClawStateDatabase( options: OpenClawStateDatabaseOptions = {}, ): OpenClawStateDatabase { + const env = options.env ?? process.env; if (options.database) { + assertOpenClawStateWriteAllowed({ + database: options.database.db, + databasePath: options.database.path, + env, + }); return options.database; } - const env = options.env ?? process.env; const pathname = resolveDatabasePath(options); // Latched paths are quarantined: the recorder closed any live handle, and // every open fails fast here until doctor repairs the file and clears it. assertOpenClawStateDatabaseOpenAllowed(options); const cached = cachedDatabases.get(pathname); if (cached?.db.isOpen) { + assertOpenClawStateWriteAllowed({ database: cached.db, databasePath: pathname, env }); return cached; } if (cached) { @@ -625,6 +588,7 @@ export function openOpenClawStateDatabase( cachedDatabases.delete(pathname); } assertOpenClawStateDatabaseFreshOpenAllowed(options); + assertOpenClawStateWriteAllowed({ databasePath: pathname, env }); ensureOpenClawStatePermissions(pathname, env); const db = openNodeSqliteDatabase(pathname); enableNodeSqliteKyselyStatementCache(db); @@ -644,7 +608,7 @@ export function openOpenClawStateDatabase( foreignKeys: true, synchronous: "NORMAL", }); - ensureSchema(db, pathname); + ensureSchema(db, pathname, env); return maintenance; } catch (err) { maintenance?.close(); @@ -680,15 +644,35 @@ export function runOpenClawStateWriteTransaction( "busyTimeoutMs" | "operationLabel" | "slowTransactionHoldMs" > = {}, ): T { - const database = openOpenClawStateDatabase(options); + const cachedBeforeOpen = options.database ?? getOpenClawStateDatabaseIfOpen(options); + let database: OpenClawStateDatabase; + try { + database = openOpenClawStateDatabase(options); + } catch (error) { + if (cachedBeforeOpen && isSqliteCorruptionError(error)) { + evictCachedOpenClawStateDatabase(cachedBeforeOpen); + } + throw error; + } let result: T; try { - result = runSqliteImmediateTransactionSync(database.db, () => operation(database), { - busyTimeoutMs: transactionOptions.busyTimeoutMs ?? OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, - databaseLabel: database.path, - ...transactionOptions, - operationLabel: transactionOptions.operationLabel ?? "state.write", - }); + result = runSqliteImmediateTransactionSync( + database.db, + () => { + assertOpenClawStateWriteAllowed({ + database: database.db, + databasePath: database.path, + env: options.env ?? process.env, + }); + return operation(database); + }, + { + busyTimeoutMs: transactionOptions.busyTimeoutMs ?? OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, + databaseLabel: database.path, + ...transactionOptions, + operationLabel: transactionOptions.operationLabel ?? "state.write", + }, + ); } catch (error) { if (isSqliteCorruptionError(error)) { evictCachedOpenClawStateDatabase(database); diff --git a/src/state/openclaw-state-ownership-operations.ts b/src/state/openclaw-state-ownership-operations.ts new file mode 100644 index 000000000000..3f0efba2c950 --- /dev/null +++ b/src/state/openclaw-state-ownership-operations.ts @@ -0,0 +1,173 @@ +import type { DatabaseSync } from "node:sqlite"; +import { isGatewayExternallySupervised } from "../infra/gateway-supervision.js"; +import { + clearNodeSqliteKyselyCacheForDatabase, + executeSqliteQuerySync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; +import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js"; +import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js"; +import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js"; +import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db-contract.js"; +import { + assertOpenClawStateDatabaseForMaintenance, + resolveDatabasePath, +} from "./openclaw-state-db-maintenance.js"; +import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; +import { + inspectOpenClawStateOwnershipFromDatabase, + normalizeOpenClawStateManagerId, + OpenClawStateOwnershipMetadataError, + STATE_SUPERVISION_KEY, + type OpenClawExternalStateOwnership, +} from "./openclaw-state-ownership.js"; + +type OpenClawStateOwnershipOptions = Omit; +type OwnershipDatabase = Pick; + +class OpenClawStateOwnershipClaimConflictError extends Error { + constructor(requestedManagerId: string, existingManagerId: string) { + super( + `OpenClaw shared state is already claimed by external manager ${existingManagerId}; ` + + `manager ${requestedManagerId} cannot replace that durable ownership.`, + ); + this.name = "OpenClawStateOwnershipClaimConflictError"; + } +} + +function requireOwnershipCheckpoint( + walMaintenance: SqliteWalMaintenance, + databasePath: string, +): void { + if (!walMaintenance.checkpoint()) { + throw new Error( + `External ownership was committed for ${databasePath}, but its WAL checkpoint failed. Retry the same ownership claim before activating the supervisor.`, + ); + } +} + +function claimOwnershipRow( + database: DatabaseSync, + databasePath: string, + managerId: string, + repairMalformed: boolean, +): OpenClawExternalStateOwnership { + let current: OpenClawExternalStateOwnership | null = null; + try { + current = inspectOpenClawStateOwnershipFromDatabase(database, databasePath); + } catch (error) { + if (!repairMalformed || !(error instanceof OpenClawStateOwnershipMetadataError)) { + throw error; + } + } + if (current) { + if (current.managerId !== managerId) { + throw new OpenClawStateOwnershipClaimConflictError(managerId, current.managerId); + } + return current; + } + const ownership: OpenClawExternalStateOwnership = { + version: 1, + mode: "external", + managerId, + claimedAt: Date.now(), + }; + const valueJson = JSON.stringify(ownership); + const stateDb = getNodeSqliteKysely(database); + executeSqliteQuerySync( + database, + stateDb + .insertInto("config_machine_state") + .values({ + state_key: STATE_SUPERVISION_KEY, + value_json: valueJson, + updated_at_ms: ownership.claimedAt, + }) + .onConflict((conflict) => + conflict.column("state_key").doUpdateSet({ + value_json: valueJson, + updated_at_ms: ownership.claimedAt, + }), + ), + ); + return ownership; +} + +function repairMalformedOwnershipClaim( + databasePath: string, + managerId: string, +): OpenClawExternalStateOwnership { + const database = openNodeSqliteDatabase(databasePath); + let walMaintenance: SqliteWalMaintenance | undefined; + try { + database.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); + assertSqliteIntegrity(database, databasePath); + assertOpenClawStateDatabaseForMaintenance(database, { pathname: databasePath }); + walMaintenance = configureSqliteWalMaintenance(database, { + busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, + checkpointIntervalMs: 0, + checkpointMode: "TRUNCATE", + databaseLabel: "OpenClaw shared state ownership", + databasePath, + }); + const ownership = runSqliteImmediateTransactionSync( + database, + () => { + assertOpenClawStateDatabaseForMaintenance(database, { pathname: databasePath }); + return claimOwnershipRow(database, databasePath, managerId, true); + }, + { + busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, + databaseLabel: databasePath, + operationLabel: "state.ownership.repair", + }, + ); + requireOwnershipCheckpoint(walMaintenance, databasePath); + return ownership; + } finally { + walMaintenance?.close({ checkpointMode: "PASSIVE" }); + clearNodeSqliteKyselyCacheForDatabase(database); + database.close(); + } +} + +/** Claim durable shared-state write ownership for the active external supervisor. */ +export function claimOpenClawStateOwnership( + managerId: string, + options: OpenClawStateOwnershipOptions = {}, +): OpenClawExternalStateOwnership { + const env = options.env ?? process.env; + if (!isGatewayExternallySupervised(env)) { + throw new Error( + "Claiming external shared-state ownership requires OPENCLAW_SUPERVISOR_MODE=external.", + ); + } + const normalizedManagerId = normalizeOpenClawStateManagerId(managerId); + try { + const database = openOpenClawStateDatabase(options); + const ownership = runOpenClawStateWriteTransaction( + ({ db, path: databasePath }) => + claimOwnershipRow(db, databasePath, normalizedManagerId, false), + { ...options, database }, + { operationLabel: "state.ownership.claim" }, + ); + requireOwnershipCheckpoint(database.walMaintenance, database.path); + return ownership; + } catch (error) { + if (!(error instanceof OpenClawStateOwnershipMetadataError)) { + throw error; + } + const ownership = repairMalformedOwnershipClaim( + resolveDatabasePath(options), + normalizedManagerId, + ); + openOpenClawStateDatabase(options); + return ownership; + } +} diff --git a/src/state/openclaw-state-ownership.test.ts b/src/state/openclaw-state-ownership.test.ts new file mode 100644 index 000000000000..d8d30d8dee2a --- /dev/null +++ b/src/state/openclaw-state-ownership.test.ts @@ -0,0 +1,342 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { runDoctorStateSqliteCompact } from "../commands/doctor-state-sqlite-compact.js"; +import { + readConfigHealthStateFromStore, + writeConfigHealthStateToStore, +} from "../config/io.health-state.js"; +import { requireNodeSqlite } from "../infra/node-sqlite.js"; +import { withOpenClawStateStartupMigrationCheckpointDatabase } from "./openclaw-state-db-startup-checkpoint.js"; +import { + closeOpenClawStateDatabaseForTest, + openExistingOpenClawStateDatabaseReadOnly, + openOpenClawStateDatabase, + repairOpenClawStateDatabaseSchema, + repairOpenClawStateDatabaseSchemaIfNeeded, + runOpenClawStateWriteTransaction, +} from "./openclaw-state-db.js"; +import { claimOpenClawStateOwnership } from "./openclaw-state-ownership-operations.js"; +import { + inspectOpenClawStateOwnershipAtPath, + OpenClawStateOwnershipError, + OpenClawStateOwnershipMetadataError, + STATE_SUPERVISION_KEY, +} from "./openclaw-state-ownership.js"; + +const tempDirs = useAutoCleanupTempDirTracker((cleanup) => { + afterEach(() => { + closeOpenClawStateDatabaseForTest(); + cleanup(); + }); +}); + +function createEnv(external = false): NodeJS.ProcessEnv { + return { + OPENCLAW_STATE_DIR: tempDirs.make("openclaw-state-ownership-"), + ...(external ? { OPENCLAW_SUPERVISOR_MODE: "external" } : {}), + }; +} + +function withoutExternalMarker(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env }; + delete next.OPENCLAW_SUPERVISOR_MODE; + return next; +} + +function claimFixture(managerId = "gateway-supervisor") { + const externalEnv = createEnv(true); + const ownership = claimOpenClawStateOwnership(managerId, { env: externalEnv }); + const databasePath = openOpenClawStateDatabase({ env: externalEnv }).path; + closeOpenClawStateDatabaseForTest(); + return { databasePath, externalEnv, ownership, unmarkedEnv: withoutExternalMarker(externalEnv) }; +} + +function snapshotSqliteFamily(databasePath: string) { + const directory = path.dirname(databasePath); + const entries = fs.readdirSync(directory).toSorted(); + return { + entries, + files: Object.fromEntries( + entries.map((entry) => { + const pathname = path.join(directory, entry); + const stat = fs.statSync(pathname, { bigint: true }); + return [ + entry, + { + bytes: fs.readFileSync(pathname), + birthtimeNs: stat.birthtimeNs, + ctimeNs: stat.ctimeNs, + dev: stat.dev, + ino: stat.ino, + mode: stat.mode, + mtimeNs: stat.mtimeNs, + size: stat.size, + }, + ]; + }), + ), + }; +} + +describe("external shared-state ownership", () => { + it("preserves ordinary unowned database behavior", () => { + const env = createEnv(); + const database = openOpenClawStateDatabase({ env }); + expect(database.db.isOpen).toBe(true); + expect(inspectOpenClawStateOwnershipAtPath(database.path)).toBeNull(); + }); + + it("requires the external marker and makes claims idempotent only for one manager", () => { + const env = createEnv(); + expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow( + /OPENCLAW_SUPERVISOR_MODE=external/u, + ); + const externalEnv = { ...env, OPENCLAW_SUPERVISOR_MODE: "external" }; + const first = claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv }); + expect(claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv })).toEqual(first); + expect( + inspectOpenClawStateOwnershipAtPath(openOpenClawStateDatabase({ env: externalEnv }).path), + ).toEqual(first); + expect(() => claimOpenClawStateOwnership("replacement-manager", { env: externalEnv })).toThrow( + /already claimed by external manager gateway-supervisor/u, + ); + }); + + it("refuses unmarked writable opens before changing the SQLite family", () => { + const fixture = claimFixture(); + const pending = openOpenClawStateDatabase({ env: fixture.externalEnv }); + pending.db.exec(` + ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json; + DROP INDEX idx_task_runs_status; + `); + closeOpenClawStateDatabaseForTest(); + if (process.platform !== "win32") { + fs.chmodSync(fixture.databasePath, 0o666); + } + for (const suffix of ["-wal", "-shm", "-journal"]) { + expect(fs.existsSync(`${fixture.databasePath}${suffix}`)).toBe(false); + } + const before = snapshotSqliteFamily(fixture.databasePath); + + expect(() => openOpenClawStateDatabase({ env: fixture.unmarkedEnv })).toThrow( + OpenClawStateOwnershipError, + ); + + expect(snapshotSqliteFamily(fixture.databasePath)).toEqual(before); + for (const suffix of ["-wal", "-shm", "-journal"]) { + expect(fs.existsSync(`${fixture.databasePath}${suffix}`)).toBe(false); + } + const repaired = openOpenClawStateDatabase({ env: fixture.externalEnv }); + expect(repaired.db.isOpen).toBe(true); + expect(repaired.db.prepare("PRAGMA table_info(worktrees)").all()).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "run_end_cleanup_json" })]), + ); + expect( + repaired.db + .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = ?") + .get("idx_task_runs_status"), + ).toBeDefined(); + }); + + it("fences a claim made immediately before cold-open schema repair", () => { + const env = createEnv(); + const databasePath = openOpenClawStateDatabase({ env }).path; + closeOpenClawStateDatabaseForTest(); + const { DatabaseSync } = requireNodeSqlite(); + const drifted = new DatabaseSync(databasePath); + try { + drifted.exec(` + ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json; + DROP INDEX idx_task_runs_status; + `); + } finally { + drifted.close(); + } + + const originalExec = Object.getOwnPropertyDescriptor(DatabaseSync.prototype, "exec")?.value as + | ((this: import("node:sqlite").DatabaseSync, sql: string) => void) + | undefined; + if (!originalExec) { + throw new Error("DatabaseSync.exec descriptor is unavailable"); + } + let immediateTransactionCount = 0; + const exec = vi.spyOn(DatabaseSync.prototype, "exec").mockImplementation(function ( + this: import("node:sqlite").DatabaseSync, + sql: string, + ) { + if (sql === "BEGIN IMMEDIATE" && ++immediateTransactionCount === 1) { + const claimant = new DatabaseSync(databasePath); + try { + claimant + .prepare( + `INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) + VALUES (?, ?, ?)`, + ) + .run( + STATE_SUPERVISION_KEY, + JSON.stringify({ + version: 1, + mode: "external", + managerId: "race-manager", + claimedAt: 1, + }), + 1, + ); + } finally { + claimant.close(); + } + } + return originalExec.call(this, sql); + }); + + try { + expect(() => openOpenClawStateDatabase({ env })).toThrow(OpenClawStateOwnershipError); + } finally { + exec.mockRestore(); + } + expect(immediateTransactionCount).toBe(1); + + const verify = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + verify + .prepare("SELECT 1 FROM pragma_table_info('worktrees') WHERE name = ?") + .get("run_end_cleanup_json"), + ).toBeUndefined(); + expect( + verify + .prepare("SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = ?") + .get("idx_task_runs_status"), + ).toBeUndefined(); + } finally { + verify.close(); + } + }); + + it("fences injected and pre-claim handles on their next canonical write", () => { + const externalEnv = createEnv(true); + const opened = openOpenClawStateDatabase({ env: externalEnv }); + claimOpenClawStateOwnership("gateway-supervisor", { env: externalEnv }); + const unmarkedEnv = withoutExternalMarker(externalEnv); + + expect(() => openOpenClawStateDatabase({ env: unmarkedEnv })).toThrow( + OpenClawStateOwnershipError, + ); + expect(() => openOpenClawStateDatabase({ env: unmarkedEnv, database: opened })).toThrow( + OpenClawStateOwnershipError, + ); + expect(() => + runOpenClawStateWriteTransaction(() => undefined, { + env: unmarkedEnv, + database: opened, + }), + ).toThrow(OpenClawStateOwnershipError); + }); + + it("reports checkpoint failure and lets the same durable claim retry", () => { + const env = createEnv(true); + const database = openOpenClawStateDatabase({ env }); + const checkpoint = vi.spyOn(database.walMaintenance, "checkpoint").mockReturnValueOnce(false); + + expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow( + /ownership was committed.*checkpoint failed/iu, + ); + checkpoint.mockRestore(); + const ownership = claimOpenClawStateOwnership("gateway-supervisor", { env }); + expect(inspectOpenClawStateOwnershipAtPath(database.path)).toEqual(ownership); + }); + + it("fails closed when unmarked and lets an external claim repair malformed metadata", () => { + const env = createEnv(true); + const database = openOpenClawStateDatabase({ env }); + database.db + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)", + ) + .run(STATE_SUPERVISION_KEY, '{"version":1,"mode":"external"}', Date.now()); + database.db.exec("ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json;"); + closeOpenClawStateDatabaseForTest(); + + expect(() => openOpenClawStateDatabase({ env: withoutExternalMarker(env) })).toThrow( + OpenClawStateOwnershipMetadataError, + ); + expect(() => openOpenClawStateDatabase({ env })).toThrow(OpenClawStateOwnershipMetadataError); + const ownership = claimOpenClawStateOwnership("gateway-supervisor", { env }); + expect(inspectOpenClawStateOwnershipAtPath(database.path)).toEqual(ownership); + expect( + openOpenClawStateDatabase({ env }).db.prepare("PRAGMA table_info(worktrees)").all(), + ).toEqual(expect.arrayContaining([expect.objectContaining({ name: "run_end_cleanup_json" })])); + }); + + it("does not repair malformed ownership before blocking schema drift", () => { + const env = createEnv(true); + const database = openOpenClawStateDatabase({ env }); + const malformed = '{"version":1,"mode":"external"}'; + database.db + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)", + ) + .run(STATE_SUPERVISION_KEY, malformed, Date.now()); + database.db.exec("ALTER TABLE worktrees ADD COLUMN unexpected_claim_column TEXT DEFAULT NULL;"); + const databasePath = database.path; + closeOpenClawStateDatabaseForTest(); + + expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow( + /column definitions differ for worktrees/u, + ); + const { DatabaseSync } = requireNodeSqlite(); + const raw = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + raw + .prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?") + .get(STATE_SUPERVISION_KEY), + ).toEqual({ value_json: malformed }); + } finally { + raw.close(); + } + }); + + it("fences Doctor repair, startup checkpoint, compaction, and config health", async () => { + const fixture = claimFixture(); + if (process.platform !== "win32") { + fs.chmodSync(fixture.databasePath, 0o666); + } + const before = snapshotSqliteFamily(fixture.databasePath); + expect(() => repairOpenClawStateDatabaseSchema({ env: fixture.unmarkedEnv })).toThrow( + OpenClawStateOwnershipError, + ); + expect(() => repairOpenClawStateDatabaseSchemaIfNeeded({ env: fixture.unmarkedEnv })).toThrow( + OpenClawStateOwnershipError, + ); + expect(() => + withOpenClawStateStartupMigrationCheckpointDatabase(() => undefined, { + env: fixture.unmarkedEnv, + }), + ).toThrow(OpenClawStateOwnershipError); + await expect(runDoctorStateSqliteCompact({ env: fixture.unmarkedEnv })).rejects.toThrow( + OpenClawStateOwnershipError, + ); + const healthDeps = { + env: fixture.unmarkedEnv, + homedir: () => fixture.unmarkedEnv.OPENCLAW_STATE_DIR ?? "", + logger: { warn: () => undefined }, + }; + expect(() => readConfigHealthStateFromStore(healthDeps)).toThrow(OpenClawStateOwnershipError); + expect(() => + writeConfigHealthStateToStore(healthDeps, { + entries: { "/tmp/openclaw.json": { lastObservedSuspiciousSignature: "test" } }, + }), + ).toThrow(OpenClawStateOwnershipError); + expect(snapshotSqliteFamily(fixture.databasePath)).toEqual(before); + }); + + it("allows read-only access without the external marker", async () => { + const fixture = claimFixture(); + const database = await openExistingOpenClawStateDatabaseReadOnly({ env: fixture.unmarkedEnv }); + expect(database?.db.isOpen).toBe(true); + database?.walMaintenance.close(); + }); +}); diff --git a/src/state/openclaw-state-ownership.ts b/src/state/openclaw-state-ownership.ts new file mode 100644 index 000000000000..9b75a304ddcf --- /dev/null +++ b/src/state/openclaw-state-ownership.ts @@ -0,0 +1,148 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { isGatewayExternallySupervised } from "../infra/gateway-supervision.js"; +import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js"; +import { tableExists } from "./openclaw-state-db-schema-helpers.js"; + +export const STATE_SUPERVISION_KEY = "gateway.supervision"; +const MAX_OWNERSHIP_TIMESTAMP_MS = 8_640_000_000_000_000; +const MANAGER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; + +export type OpenClawExternalStateOwnership = { + claimedAt: number; + managerId: string; + mode: "external"; + version: 1; +}; + +export class OpenClawStateOwnershipError extends Error {} + +export class OpenClawStateOwnershipMetadataError extends OpenClawStateOwnershipError { + constructor( + readonly databasePath: string, + message: string, + ) { + super( + `OpenClaw shared state ownership metadata is invalid at ${databasePath}: ${message}. ` + + "Repair it with OPENCLAW_SUPERVISOR_MODE=external openclaw database ownership claim --manager .", + ); + this.name = "OpenClawStateOwnershipMetadataError"; + } +} + +class OpenClawStateExternalOwnershipError extends OpenClawStateOwnershipError { + constructor( + readonly databasePath: string, + readonly managerId: string, + ) { + super( + `OpenClaw shared state database ${databasePath} is externally supervised by ${managerId}. ` + + "Use that external supervisor with OPENCLAW_SUPERVISOR_MODE=external for writable operations.", + ); + this.name = "OpenClawStateExternalOwnershipError"; + } +} + +export function normalizeOpenClawStateManagerId(managerId: string): string { + const normalized = managerId.trim(); + if (!MANAGER_ID_PATTERN.test(normalized)) { + throw new Error( + "External state ownership manager id must be a 1-128 character ASCII identifier.", + ); + } + return normalized; +} + +function parseExternalOwnership( + valueJson: string, + databasePath: string, +): OpenClawExternalStateOwnership { + let value: unknown; + try { + value = JSON.parse(valueJson) as unknown; + } catch { + throw new OpenClawStateOwnershipMetadataError(databasePath, "reserved value is not valid JSON"); + } + const record = isRecord(value) ? value : undefined; + const keys = record ? Object.keys(record).toSorted().join(",") : ""; + const managerId = record?.managerId; + const claimedAt = record?.claimedAt; + if ( + keys !== "claimedAt,managerId,mode,version" || + record?.version !== 1 || + record?.mode !== "external" || + typeof managerId !== "string" || + !MANAGER_ID_PATTERN.test(managerId) || + typeof claimedAt !== "number" || + !Number.isSafeInteger(claimedAt) || + claimedAt < 0 || + claimedAt > MAX_OWNERSHIP_TIMESTAMP_MS + ) { + throw new OpenClawStateOwnershipMetadataError( + databasePath, + "reserved value does not match the version 1 external ownership contract", + ); + } + return { + version: 1, + mode: "external", + managerId, + claimedAt, + }; +} + +/** Inspect the reserved ownership row without entering the shared-state lifecycle. */ +export function inspectOpenClawStateOwnershipFromDatabase( + database: DatabaseSync, + databasePath: string, +): OpenClawExternalStateOwnership | null { + if (!tableExists(database, "config_machine_state")) { + return null; + } + const row = database + .prepare("SELECT value_json FROM config_machine_state WHERE state_key = ? LIMIT 1") + .get(STATE_SUPERVISION_KEY) as { value_json?: unknown } | undefined; + if (!row) { + return null; + } + if (typeof row.value_json !== "string") { + throw new OpenClawStateOwnershipMetadataError(databasePath, "reserved value is not text"); + } + return parseExternalOwnership(row.value_json, databasePath); +} + +/** Inspect one resolved state database path through a read-only connection. */ +export function inspectOpenClawStateOwnershipAtPath( + databasePath: string, +): OpenClawExternalStateOwnership | null { + const resolvedPath = path.resolve(databasePath); + if (!existsSync(resolvedPath)) { + return null; + } + const database = openNodeSqliteDatabase(resolveImmutableSqliteFileUri(resolvedPath), { + readOnly: true, + }); + try { + database.exec("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;"); + return inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath); + } finally { + database.close(); + } +} + +/** Fence shared-state writes once an external manager has claimed ownership. */ +export function assertOpenClawStateWriteAllowed(options: { + database?: DatabaseSync; + databasePath: string; + env?: NodeJS.ProcessEnv; +}): void { + const resolvedPath = path.resolve(options.databasePath); + const status = options.database + ? inspectOpenClawStateOwnershipFromDatabase(options.database, resolvedPath) + : inspectOpenClawStateOwnershipAtPath(resolvedPath); + if (status && !isGatewayExternallySupervised(options.env ?? process.env)) { + throw new OpenClawStateExternalOwnershipError(resolvedPath, status.managerId); + } +} diff --git a/src/state/openclaw-state-schema-compatibility.ts b/src/state/openclaw-state-schema-compatibility.ts new file mode 100644 index 000000000000..b52d8613101f --- /dev/null +++ b/src/state/openclaw-state-schema-compatibility.ts @@ -0,0 +1,123 @@ +import { + getCanonicalSqliteNamedIndexContracts, + type SqliteSchemaCompatibility, + type SqliteSchemaIssue, +} from "../infra/sqlite-schema-contract.js"; +import { + FIRST_USE_STATE_INDEXES, + FIRST_USE_STATE_TABLES, + LAZY_ADDITIVE_STATE_INDEXES, + LAZY_ADDITIVE_STATE_TABLES, +} from "./openclaw-state-db-contract.js"; +import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; + +// Same-version databases may lack additive columns that only a writable open +// can ensure, while read-only planning must keep accepting the older shape. +const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [ + "claw_installs.bootstrap_content_digest", + "claw_installs.bootstrap_source_path", + "worker_environments.desktop_json", + "claw_package_refs.extension_adapter_identity", + "claw_package_refs.extension_detected_format", + "claw_package_refs.extension_format", + "claw_package_refs.extension_id", + "claw_package_refs.extension_mapped_json", + "claw_package_refs.extension_unavailable_json", + "worker_environments.shared_host", + "worktrees.run_end_cleanup_json", +] as const; + +const CLAW_LAZY_ADDITIVE_STATE_COLUMN_SET = new Set(CLAW_LAZY_ADDITIVE_STATE_COLUMNS); +let openClawStateCanonicalNamedIndexSet: ReadonlySet | undefined; + +function getOpenClawStateCanonicalNamedIndexSet(): ReadonlySet { + openClawStateCanonicalNamedIndexSet ??= new Set( + getCanonicalSqliteNamedIndexContracts(OPENCLAW_STATE_SCHEMA_SQL).map((index) => index.name), + ); + return openClawStateCanonicalNamedIndexSet; +} + +/** Project canonical SQL to the tables the shared runtime may create during this open. */ +export function getOpenClawStateRuntimeSchema(options: { + includeVersionLazyAdditiveTables: boolean; +}): string { + let schema = OPENCLAW_STATE_SCHEMA_SQL; + const omittedTables = options.includeVersionLazyAdditiveTables + ? FIRST_USE_STATE_TABLES + : LAZY_ADDITIVE_STATE_TABLES; + const omittedIndexes = options.includeVersionLazyAdditiveTables + ? FIRST_USE_STATE_INDEXES + : LAZY_ADDITIVE_STATE_INDEXES; + for (const tableName of omittedTables) { + const start = schema.indexOf(`CREATE TABLE IF NOT EXISTS ${tableName} (`); + const endMarker = "\n) STRICT;"; + const end = start >= 0 ? schema.indexOf(endMarker, start) : -1; + if (start < 0 || end < 0) { + throw new Error(`lazy additive state schema block is missing for ${tableName}`); + } + schema = `${schema.slice(0, start)}${schema.slice(end + endMarker.length)}`; + } + for (const indexName of omittedIndexes) { + const start = schema.indexOf(`CREATE INDEX IF NOT EXISTS ${indexName}`); + const end = start >= 0 ? schema.indexOf(";", start) : -1; + if (start < 0 || end < 0) { + throw new Error(`lazy additive state schema index is missing for ${indexName}`); + } + schema = `${schema.slice(0, start)}${schema.slice(end + 1)}`; + } + return schema; +} + +export const STATE_PERSISTENT_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = { + allowCompatibleAdditiveColumns: true, + allowedColumnDefinitions: { + "diagnostic_events.sequence": ["sequence INTEGER NOT NULL DEFAULT 0"], + "commitments.attempts": ["attempts INTEGER NOT NULL DEFAULT 0"], + "commitments.confidence": ["confidence REAL NOT NULL DEFAULT 0"], + "commitments.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"], + "commitments.dedupe_key": ["dedupe_key TEXT NOT NULL DEFAULT ''"], + "commitments.due_timezone": ["due_timezone TEXT NOT NULL DEFAULT 'UTC'"], + "commitments.kind": ["kind TEXT NOT NULL DEFAULT 'followup'"], + "commitments.reason": ["reason TEXT NOT NULL DEFAULT ''"], + "commitments.sensitivity": ["sensitivity TEXT NOT NULL DEFAULT 'normal'"], + "commitments.source": ["source TEXT NOT NULL DEFAULT 'unknown'"], + "commitments.suggested_text": ["suggested_text TEXT NOT NULL DEFAULT ''"], + "claw_package_refs.package_integrity": [ + "package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'", + ], + "claw_package_refs.updated_at_ms": ["updated_at_ms INTEGER NOT NULL DEFAULT 0"], + "cron_jobs.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"], + "cron_jobs.enabled": ["enabled INTEGER NOT NULL DEFAULT 1"], + "cron_jobs.name": ["name TEXT NOT NULL DEFAULT ''"], + "cron_jobs.payload_kind": ["payload_kind TEXT NOT NULL DEFAULT 'message'"], + "cron_jobs.schedule_kind": ["schedule_kind TEXT NOT NULL DEFAULT 'manual'"], + "cron_jobs.session_target": ["session_target TEXT NOT NULL DEFAULT 'main'"], + "cron_jobs.wake_mode": ["wake_mode TEXT NOT NULL DEFAULT 'auto'"], + "current_conversation_bindings.conversation_kind": [ + "conversation_kind TEXT NOT NULL DEFAULT 'channel'", + ], + "current_conversation_bindings.target_agent_id": [ + "target_agent_id TEXT NOT NULL DEFAULT 'main'", + ], + "operator_approvals.resolution_ref": ["resolution_ref TEXT"], + "worker_environments.desktop_json": ["desktop_json TEXT"], + "worker_environments.shared_host": ["shared_host INTEGER CHECK (shared_host IN (0, 1))"], + }, +}; + +export const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = { + ...STATE_PERSISTENT_SCHEMA_COMPATIBILITY, + allowedMissingTables: LAZY_ADDITIVE_STATE_TABLES, + allowedMissingColumns: CLAW_LAZY_ADDITIVE_STATE_COLUMNS, +}; + +/** Identify schema differences that the writable shared-state cold open repairs. */ +export function isOpenClawStateStartupRepairableSchemaIssue(issue: SqliteSchemaIssue): boolean { + if (issue.code === "missing-column") { + return CLAW_LAZY_ADDITIVE_STATE_COLUMN_SET.has(issue.objectName); + } + return ( + issue.code === "missing-or-drifted-index" && + getOpenClawStateCanonicalNamedIndexSet().has(issue.objectName) + ); +} diff --git a/test/gateway-external-state-ownership.e2e.test.ts b/test/gateway-external-state-ownership.e2e.test.ts new file mode 100644 index 000000000000..959a2c88f0ba --- /dev/null +++ b/test/gateway-external-state-ownership.e2e.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { createOpenClawTestInstance } from "./helpers/openclaw-test-instance.js"; + +describe("Gateway external shared-state ownership", () => { + it("refuses unmarked startup and accepts the external supervisor marker", async () => { + const instance = await createOpenClawTestInstance({ + name: "gateway-external-state-owner", + env: { OPENCLAW_SUPERVISOR_MODE: "external" }, + startTimeoutMs: 30_000, + }); + try { + const claim = await instance.cli([ + "database", + "ownership", + "claim", + "--manager", + "gateway-supervisor", + "--json", + ]); + expect(claim.code, claim.stderr).toBe(0); + const claimed = JSON.parse(claim.stdout) as { + databasePath: string; + ownership: { managerId: string }; + status: string; + }; + expect(claimed).toMatchObject({ + status: "external", + ownership: { managerId: "gateway-supervisor" }, + }); + const preflight = await instance.cli([ + "database", + "preflight", + claimed.databasePath, + "--json", + ]); + expect(preflight.code, preflight.stderr).toBe(0); + expect(JSON.parse(preflight.stdout)).toMatchObject({ + schema: "openclaw.state-schema-preflight.v1", + status: "exact", + requiresWrite: false, + }); + const unreadable = await instance.cli([ + "database", + "preflight", + `${instance.stateDir}/missing.sqlite`, + "--json", + ]); + expect(unreadable.code).toBe(1); + expect(JSON.parse(unreadable.stdout)).toMatchObject({ + schema: "openclaw.state-schema-preflight.v1", + status: "indeterminate", + }); + const status = await instance.cli(["database", "ownership", "status", "--json"]); + expect(status.code, status.stderr).toBe(0); + expect(JSON.parse(status.stdout)).toMatchObject({ + status: "external", + ownership: { managerId: "gateway-supervisor" }, + }); + const conflictingClaim = await instance.cli([ + "database", + "ownership", + "claim", + "--manager", + "replacement-manager", + "--json", + ]); + expect(conflictingClaim.code).toBe(1); + expect(JSON.parse(conflictingClaim.stdout)).toMatchObject({ + error: expect.stringContaining("already claimed by external manager gateway-supervisor"), + }); + + delete instance.env.OPENCLAW_SUPERVISOR_MODE; + await expect(instance.startGateway()).rejects.toThrow(/gateway-supervisor/u); + expect(instance.logs()).toMatch(/OPENCLAW_SUPERVISOR_MODE=external/u); + + instance.env.OPENCLAW_SUPERVISOR_MODE = "external"; + await instance.startGateway(); + expect(instance.child).toBeDefined(); + } finally { + await instance.cleanup(); + } + }); +});