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
This commit is contained in:
Peter Steinberger
2026-08-09 14:04:40 -07:00
committed by GitHub
parent bff0494f33
commit f31d9d8fa9
41 changed files with 2885 additions and 577 deletions
+46 -46
View File
@@ -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
+13
View File
@@ -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 <copied-state.sqlite> --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:
+24 -2
View File
@@ -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 <copied-state.sqlite> --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.
+2
View File
@@ -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",
+3 -2
View File
@@ -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};`);
}
+11
View File
@@ -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 },
+1
View File
@@ -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"],
+5
View File
@@ -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"),
@@ -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",
+99
View File
@@ -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("<path>", "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 <id>", "stable external manager identifier")
.option("--json", "emit machine-readable JSON", false)
.action((options: DatabaseOutputOptions & { manager: string }) =>
runDatabaseOwnership(options),
);
applyParentDefaultHelpAction(ownership);
applyParentDefaultHelpAction(database);
}
@@ -28,6 +28,8 @@ const JSON_NOT_APPLICABLE = {
commands: [
"backup",
"backup sqlite",
"database",
"database ownership",
"message",
"message thread",
"message emoji",
+26 -10
View File
@@ -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<unknown>>(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"]);
+12 -7
View File
@@ -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<OpenClawConfig> => {
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<typeof createGatewayStartupTrace> => 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,
});
@@ -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);
});
});
+8
View File
@@ -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<DoctorConfigPreflightResult> {
const stateMigrationsRequested = options.migrateState !== false;
if (stateMigrationsRequested) {
assertOpenClawStateWriteAllowed({
databasePath: resolveOpenClawStateSqlitePath(process.env),
env: process.env,
});
}
const measurePreflightStep = <T>(name: string, run: () => T | Promise<T>) =>
measureDoctorConfigPreflightStep(name, run, options.measure);
const gatewayStartupCheckpointRequired = options.requireStartupMigrationCheckpoint === true;
+6 -6
View File
@@ -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,
+8 -1
View File
@@ -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)}`);
}
}
+6
View File
@@ -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,
+14 -1
View File
@@ -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", () => {
+15
View File
@@ -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;
+107 -1
View File
@@ -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 {
+96 -294
View File
@@ -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<Record<string, readonly string[]>>;
/**
* 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<string, SqliteSchemaContract>();
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);
}
+125
View File
@@ -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<Record<string, readonly string[]>>;
/**
* 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("; ")}`,
);
}
+209
View File
@@ -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;
}
+85 -1
View File
@@ -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-"),
+6 -1
View File
@@ -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<T>(
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);
}),
);
}
@@ -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<string>();
type GatewayRestartSentinelDatabase = Pick<OpenClawStateKyselyDatabase, "gateway_restart_sentinel">;
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<string, unknown>;
};
},
): void {
const { db } = openOpenClawStateDatabase({ env });
const stateDb = getNodeSqliteKysely<GatewayRestartSentinelDatabase>(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> | void;
whileHelperRunning?: (context: { logPath: string }) => Promise<void> | void;
}) {
const { execFile } =
await vi.importActual<typeof import("node:child_process")>("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<ReturnType<typeof runOwnershipHelper>> | 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<void>((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,
);
});
});
+94 -2
View File
@@ -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 {}
+29
View File
@@ -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(
+79 -46
View File
@@ -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<string, number>): CaptureObservedDimensi
.toSorted((left, right) => right.count - left.count || left.value.localeCompare(right.value));
}
type SharedDebugProxyCaptureState = {
database: OpenClawStateDatabase;
env?: NodeJS.ProcessEnv;
};
const sharedDebugProxyCaptureStates = new WeakMap<object, SharedDebugProxyCaptureState>();
function runSharedDebugProxyCaptureWrite<T>(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(
@@ -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}`,
+282 -1
View File
@@ -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";
+150 -3
View File
@@ -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<OpenClawStateKyselyDatabase, "agent_databases">;
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<OpenClawStateSchemaPreflightResult> {
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;
+3 -66
View File
@@ -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,
);
}
@@ -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>(
): 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<T>(
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
});
assertSqliteIntegrity(db, pathname);
ensureStartupMigrationCheckpointSchema(db, pathname);
ensureStartupMigrationCheckpointSchema(db, pathname, env);
return callback(db);
} finally {
db.close();
+67 -83
View File
@@ -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<OpenClawStateKyselyDatabase, "schema_meta">;
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<string>();
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<OpenClawStateMetadataDatabase>(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<T>(
"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);
@@ -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<OpenClawStateDatabaseOptions, "database" | "readOnly">;
type OwnershipDatabase = Pick<OpenClawStateKyselyDatabase, "config_machine_state">;
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<OwnershipDatabase>(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;
}
}
+342
View File
@@ -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();
});
});
+148
View File
@@ -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 <manager-id>.",
);
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);
}
}
@@ -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<string>(CLAW_LAZY_ADDITIVE_STATE_COLUMNS);
let openClawStateCanonicalNamedIndexSet: ReadonlySet<string> | undefined;
function getOpenClawStateCanonicalNamedIndexSet(): ReadonlySet<string> {
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)
);
}
@@ -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();
}
});
});