diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index b160f67384a2..f1a203b8118a 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -154,23 +154,23 @@ runs: # Blacksmith transparently accelerates the upstream Actions cache API. # The scheduled/dispatch warmer writes one immutable protected archive; # all CI shards restore it into isolated runner-local directories. - key: ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }} + key: ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', 'src/state/*.sql', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | - ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}- + ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', 'src/state/*.sql', '!**/node_modules/**') }}- - name: Restore Vitest transform cache if: inputs.vitest-fs-cache == 'true' && inputs.save-vitest-fs-cache != 'true' && runner.os != 'Windows' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: /var/tmp/openclaw-vitest-fs-cache - key: ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }} + key: ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', 'src/state/*.sql', '!**/node_modules/**') }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | - ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }}- + ${{ github.repository }}-vitest-fs-v3-protected-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', 'src/state/*.sql', '!**/node_modules/**') }}- - name: Configure Vitest transform cache if: inputs.vitest-fs-cache == 'true' && runner.os != 'Windows' env: - CACHE_GENERATION: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', '!**/node_modules/**') }} + CACHE_GENERATION: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '**/package.json', '**/tsconfig*.json', 'vitest.config.*', 'test/vitest/**', 'src/state/*.sql', '!**/node_modules/**') }} CACHE_WRITER: ${{ inputs.save-vitest-fs-cache == 'true' && '1' || '0' }} shell: bash run: | diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index d9be76863cc6..15a6066bbd32 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -3,22 +3,22 @@ df71299237a4752d6b19ab0cf1d3479ca3e1f3ccb4481d96ec09d4c4be4a4c3a module/account 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id 9017717bd6213d60ad3906e92b25f2c7eb02fe47ba1c674b45135913a9d134d1 module/account-resolution 3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives -2e0b4e5b1875b1c83f1da819ba4fd4bdb9b83c9d8668cd94354675dc2d6c368c module/agent-harness -1c9807fe097ce03a2bf6450bedd847f097e9068bbfe6ab92cf16ef20d194badc module/agent-harness-runtime +b73d4807d22bd8c395fdf360df6da80eadf118564f76ef58547b6dc32dad3f74 module/agent-harness +1240dcaef528d30149a5f68baa8d74ed2fc27e4ea0e2b7f4f2e66da9d67bc214 module/agent-harness-runtime 773943f0f5cc26d4bfd1dc6cfdac5effc2bec14f1bde927e9e407ab4c9701ba0 module/agent-media-payload -3e3c8d6dd7dcc6720c80a8a37c2ea728c1e398ce844a26a73a10a092bfa8e189 module/agent-runtime +d841da1422a4914d6eea760c8d32326e210dba901ee68294b3cbe09b19a4fc51 module/agent-runtime 241d467d0af5f81d8a535fe0c65d226356daf8325a037cb23092b8dd82aaa456 module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from a089721688670b451885e3100043b2b978709f20fbb06440cdb3f1231b114b65 module/allowlist-config-edit -4b627c56f39130d18443c425bbc63ebf46ad0714a6539ad343458722471a95b2 module/approval-auth-runtime -07be16e819e9905e2c1d641e99bb82cf76c3d02028dc8d7a70fc71c1c833feab module/approval-client-runtime -1643420e91031cb4391627f4f01436d68ab139983b5d8cbb7c26321647ce4692 module/approval-delivery-runtime +0d28d49daa0e5a6329f78c5fb85a7690b5105eff95a9b08b27baee6d34d536b6 module/approval-auth-runtime +f2df07078abca83be93b13ad71aaa326ae79d59e9aafdb668980919e78d2d14b module/approval-client-runtime +40564751f3c7e73b7de54dffce0c2a7c3be0f2ab2c1c20b90cf87d7a342f24e8 module/approval-delivery-runtime d062dd49caf3c9cded3ba79d7c8e1feef6b029c53f53056939400a7eeb2cc52a module/approval-gateway-runtime 5da9a30393531c72d5df3ea256eaec71ca7ad323ffe9539d27a7bfc4c75d279f module/approval-handler-adapter-runtime cf86b1cc04b5ce648d44f9e3e2d62023eabe9d69587b0713874000af903f3140 module/approval-handler-runtime -610737d0b3a00490c6468e171264fdc864c3dcee4379c62a6de06d37a1f70f45 module/approval-native-runtime +9ebebacc8d37b1fa640074ecb5018fa2f731bc1ea373f3e218b856b01504deeb module/approval-native-runtime d577aded81ae440803d3f75a679073655d86099fbfdb2b85430b76bdb8fdaa7b module/approval-reply-runtime -ad3fec58b26b75579b7bcabc01ac93d8f8f6468355014f7cf272f0ef0b021caa module/approval-runtime +173c7fd1d2a268312d7bbd2f549a650d657f482005f4325ab2ae7baf448ffb21 module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param 39769190fc9d790ae5e2c4ebd5de0a78b45b3d529aaf264c84eba4e7f86a3066 module/channel-actions @@ -26,57 +26,57 @@ d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives 76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema 429a622f16518dc102357238db361966076439c09458d61e0281e2d298f03938 module/channel-contract -1c29f5d1915260de36c099d8202e7831a0186540a61b451ff5fd9ede029d04c0 module/channel-core +60809ef50c308f97bc4b102ba77383b4a4d6252ca5d31699ed7a56ebb26f438e module/channel-core 1791224e2302e91d14efd7d3724a4aa0826e6d4df6b766bea27555e031505d75 module/channel-dm-policy -e38754573c8cdf5f5ed045414d9d90826e744cff84dc24c0cbfc7f4c16f88a97 module/channel-entry-contract +73a6dbdbe8049686d01fb6480690041522b917568a676cb8546d8e3549e289c1 module/channel-entry-contract 2c55b3f3d1d275f760a7e1c78764e4030e7a06c4273a4d51d8f3c82b55ea818d module/channel-feedback -449f1895ebf3f8aab59a1aa1d73be38171619e422a7868257e1e777694f503c4 module/channel-inbound +6d0009378e2a7453a4313f672b428afc03f71f277b1fbedcbabd8a6ce798dbb9 module/channel-inbound d7e21bb831ad5125e6498e88fcc27e820ed296784eb2296c22795c08d55ade06 module/channel-inbound-debounce 79a8f244b0627ce4b601231bf71f0ee539f6ac7692e490847ec8e7ace7d29f1c module/channel-ingress-runtime db1c401ab2c5ad89fa801580ac495ba91291de6dba2449aa6b25e0f078469040 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -7838c1acdf6acbbfe2eadaccf57d9e81154d49c4d9bdd369acd2e4f5170c1b14 module/channel-message -04c1ffa60d185299a5aad03d00abfb94ab113f9bf768273adf40025f9381b387 module/channel-outbound -b3195fc68d350a76f49f0274ff75c1a493368cda86762254c3b593ce4b71f331 module/channel-pairing -c21885f534148b76e6cbe8dcb99031bd883bd65e42ba5a30781df5e8f570de35 module/channel-plugin-common +5ee27584a0de819c7c64acac6afa189d4ba2be49fbb09a25c56b2f925d04813f module/channel-message +601144dad8d47ba52a07f5142d6b7db6bebd3981ce69df6b1af1863979ca4e2b module/channel-outbound +0168ad92d1db16eb19397ec44748adaae69dbeb6274dcf7905c467bcbe11a11a module/channel-pairing +ef5277644ea81f342bbf4503204f36f9c2eef69b4749920337fd468593ded2b3 module/channel-plugin-common 06d2928491181215a1814a89f62cae1611ef9b422593bddf12d888a1e6ae91a4 module/channel-policy -f9e488182dc596fff277530f17440479035fcc23565804e370dcd48967302182 module/channel-reply-pipeline +a547b035d75d8072b684f343e3d18fb4f87d05fec00b9cc817c6d92d676f2dc9 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context 04948928b3cf310c4c401bdb89e94ac2ff2066b5b464a9bb501b36dbe7711c07 module/channel-secret-basic-runtime e99d6f57a89503e67035da553b0ae62b893722e564031c050f3097cf5fa3bf5f module/channel-secret-runtime -8667d3a48d2958f9d25fc82316e7fc15eb7f54c6d6456710c4b0e97e3c29ba2c module/channel-send-result +05666d6267ac7e578d7603fd440b9baceb28434b164d5048a9ef273bebba2a8e module/channel-send-result 9da2b56ecda12f5cdf40b5cece3f27f7ef385e9f95038bf7edf559295ad2da16 module/channel-setup bb5b3388b642d4dac5d42746d9c2b7d6eb69b4057346b6bdcdf4cec7dcb8091c module/channel-status 95dc206f832a0f563238dcea72d41554f409a3a9df081f41c52a8241c7dc5161 module/channel-streaming 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config 1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -51ab2780cea40f8f55ca19fce4d5be3cf32bde9998b5ca4eed1706aadd080ee2 module/command-auth -3c53a787a3cb7b40c79c5cd13793675e1c41927cf1e1e1ec26581206766ecd5d module/command-auth-native +48ed41d760f2a77d0c4e0c93f081ab49d46ad2ee2a7a2e81a690e74a3b058ca2 module/command-auth +85ab6a05d59eb0dbb7333782565044a0e5ef7a115333607ea8cd1600d334e0fd module/command-auth-native a41d9effc1656cbc131611099bdeb81423ab48adce8c1b1ba07100dff8d32818 module/command-detection 31044216c6495728a36dbe60da31f7c1ede2b069cd0d0685d887ebca31814d5f module/command-primitives-runtime e42cc234ace96a64cec0bdb8051ddded77618e384ae2bbce8b633bf35dbd0288 module/command-status 91f95003a7ce8d78af219f6997e3001a7690674c2682ffe987942d3b1f45d8ea module/config-contracts 5e866c4f8dea30045a8a94e037f0e202d597afb1ca221d9854b15bc416503643 module/config-mutation -126a5382061a60bcebbeed7a746f3a84081d49460e4a7c64cf2121040937f373 module/config-runtime +c66b3cf0f9591dcfcba4b5108d09cd81670aafdb210220d25f86f335b4860da7 module/config-runtime 8b531387918aff0c41ded4233c62c42958a86d110d2e854a393d8bbf35f08599 module/conversation-runtime -2d62cb1c85d03075d76bb5329535f73942176c79fd207e768e08f11a31e03d21 module/core -0b64644c3f4ced238e52b3f83071b658397dbcae68a6567bdbae09c5919d8039 module/dedupe-runtime +95709c866e7fa8fd917d40917c0a8487c713a94e8b31d51c4fc6a36b0eb03115 module/core +a2832a6208987d80e5d918778e500f3523e38df08b875f7131ee78c853330b85 module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap fd7c489415aa272af724ac15e95d297eac0edeb229e30d02c4061d5276f874b2 module/diagnostic-runtime b6d5d4acbeb7530ffd06ab34b0f8253b6b3eba9eff2d65e132c4ad506ad5f7c8 module/directory-runtime -f6859126630329eff77a88f4f9fdb39cb5d971e5c845c4f32e62596283cdd2d2 module/discord +c4dcda99108f58a36414152e86fc150f99ea495e43a7e49f73efb6b280cb7f63 module/discord c468c0ca5e5fc093ef5bd0cd15c57e19059bbe5c453d68f2664e9aaa35661cab module/error-runtime 6d9b6396888d7cddded108e211053559b10d79397d20098e0616767be4a4bfb3 module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -fc3f7aef0d13937e0511fa6780dc61463797cba6485f92747d048e38bfb6bd68 module/gateway-runtime +67b78765e1b1d5768cae44046d49ff9d0a61bc71622551702c037a519d0bab18 module/gateway-runtime 575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access 69ca093cce7bf8283eae64d7f1d5e29c0a736ffcbcc3231e438ed05e8a9885d9 module/health -1283931681b09356f350e3dff44805c836dbe394476d5dff26110a2ade1a0708 module/hook-runtime +648fdd8d7ea3505342ccb59ac396602b63d589a8b9651b2f39a42f30a8615e6e module/hook-runtime 185a5acedbd7f1a73e5cc773e22bf494b124a09bcd68b5a756ff0f881abf431b module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -af24dd6661ea1fec5f20ce7d6312e0434d74b1934e1c5fb07f09be072ae437a0 module/inbound-reply-dispatch -00013f07021ecae422019e7c281c901ca08c440fa21ef19f30549871f458dc26 module/infra-runtime +64b8058fb45b2070e6b7fcfbd1d6b219c59109176db811b4d4818a424a194a02 module/inbound-reply-dispatch +c628bb11314188daaf02de8ef44cc69d4342af2165873f372458c4ef0dff26ce module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once f6c42d02df87ec83486ecfdc73027a628f65f2bbd1a5a0f502adf00d68655740 module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store @@ -85,32 +85,32 @@ e907fd3a98185f2c261f2aafcaa5a19ee1d7b459d519a498397d629f84c68312 module/lazy-ru f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix 7869c8dcea3b96ab00a33fcbd21a6ca171131b0c7dc6a527bf1da178c092621e module/media-local-roots f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime -ff0a99f782840729591d5438d9af2bc53b1d70dc2aa6a2f49bddec28c2dc6f4a module/media-runtime +4d35dced50510b1cc74a5e4850889b4a01367058381333ec47acf3a8bc9ce86f module/media-runtime 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store 7dd4a69b33196e946b03a3579e3b569176ff32db22fa668d5eacc06227574cd1 module/media-understanding d2301239c1beb0b24d33ac49e7bfc6b22c787911a8161def521e024a390a7fa0 module/media-understanding-runtime -b7de4cb0ac78de8a6d57d607ec8ae8e0eb30e289fdd3c571be28356986f5f0b8 module/meeting-runtime +4b481d8178ae7383561a7dc04f725d72afbed9be0514d0443d36f6ca4dcb5831 module/meeting-runtime d16cbced4f2e6672ac9a032ac41691fe7ff4994e328d44ed6ac8a46dbbec834f module/memory-core-host-engine-foundation -46baacc89cc517430a8c4cb0f8266895fdc768c25359aa1f504150211b2e2c5d module/memory-host-core +878304d213786290199faf76d5d08b0dfddd1244c056e598cec15f4e65b7c851 module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets -6bd4725c3b6a7b18cd3180b466c1fcf9cb8b2b74957fcb481b591e90a91cd735 module/model-session-runtime -deeb0f66c85990ff566e88abc3b9eed4a9a10dad439f1b346821eed8a189b659 module/models-provider-runtime +b679bab01e041322bc44fd65137931dab638ce50144f7bbd81184fe50ab8c403 module/model-session-runtime +afe4788b3a8dbb14cc9d74a5365997f323b9ee2b1471b6af8e9a5dc5ec6947d1 module/models-provider-runtime d7d1843f5958c67cbdcd816aa9a280b534cb7b9d30c6c7441fec34789b973e15 module/native-command-config-runtime faaa22538f3459cef412c52088cb40dc732aa560fba2e70655938460022287d0 module/native-command-registry 5b968ecbef95fda927d0944409994e355ec878608dd084358970078d2ac545a9 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe 1bf4d4dfe5a4b264cf6fb8fbd0c7bc76f520ff9845cffad6da4b3a3c2bc3f6f6 module/plugin-config-runtime -4268c1b1ca2de438bf2f57f100435c57b20be45be68d7153be122aaf7127335e module/plugin-entry -feee2fb45e04385c98a518a10e02593df3af322f534f75ba4393800e5513ce8f module/plugin-runtime -4611318dde4f23d050693fcaa1caddf3a88f29d06c739f915d78bac106137b3e module/provider-auth -5b371d4afc4e7986e23d4cc198e2167d22270d0f4a2979d564bd26d7f655ea5c module/provider-catalog-runtime +09b7f6bd889a74ca01fb33f8d7f537643810b125b5aaf453c8e149a7a0c51e67 module/plugin-entry +c4128b9153d21fde3cad0439aa321bbefc111d6c5597b6e04d32d43fabe58dc7 module/plugin-runtime +89d9aa7ea6085e805acbfcc841bd5299d53fe0ef909d922503f4890d6aecf394 module/provider-auth +efcc9eda2023bbb3ad8813d8c9e4290992950c137599c74bfb908626661a5f20 module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture 8bcec5b6394d92c751153ceef6ebac36f548ddad9916c98d54c12e96cfab657f module/question-gateway-runtime ca1cc8a9cd34068aceb2a96cae9c02f2cb4b9a09d7cf6124a588c21be2d7719d module/reply-chunking -fead5949fb305d2988f416213ea08fe236229e3d3581daad074450752deeadce module/reply-dispatch-runtime +82c6066eedc075fd40c4966ac82b6823b14d4abdfa22bfb9ea3b9e295e572f17 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history e023767a2b193c8404b3a2992cad4c5b71d944c2ffa29ddf180749d488d84721 module/reply-payload -3bbf5d66defdbf5f5c332cf4867b9956e50d690d7de4429293f3f9f2dc1bb0b0 module/reply-runtime +11f303679ee6a967bced2d91e55b26d81f407063ad04010fe8cc8ae90f97480d module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk e26cc92679c768fa1474f15828f545aec87f32718a6b35f7907c4f56f65542fc module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command @@ -118,20 +118,20 @@ c83779c80c9e7b196b31a39ec4b986098bd701b089bf6eb2f53c368fb982ea77 module/runtime 159b563aad773cef67f18bf9bd2653420bec94b599e052f2a1d5a238bf341e16 module/runtime-config-snapshot 9fe5bcb52b462010214eda1c01f60b3a018837d9f95dc864f6d457cb3da001cf module/runtime-env 7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy -65d5c8a051f924e813e95d96fd4e809cfce00f1fbd9e796751e5bac7e23f3d69 module/runtime-store +c302ff53a8f5c54c9a211b3139a918371ce0c306c38584f53a3a286f268ee9c0 module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input e7672788f052a1249839c8fa24d6a9a45967c4e3fd94e890b0f90296026cd1ca module/secret-input-runtime 026631cbf010d0325be2c4fa4ccd8bbdf8e1008f0904c742c270811f578b07b8 module/secret-ref-runtime 6a642666b8615801d4ddde14f6d96f8e9d86c152990ec6ca95e135cd567bb1d8 module/security-runtime -d52dd2ffa1fc0f2c6203a6b818288c7bf05a543ca94b2c394f375fb51476d8dd module/session-catalog -61d35aa316ad242d7e89376ef44c4679f80aad0dcb0611f928621367bcf4d98f module/session-discussion -f4177ab533b860b5104b9a3a8a74f807624dd1933cffc97fde5cefa9a2d09e79 module/session-store-runtime +372141941bd2f3dcca6a3b0daa20e8ab1d1737e95e07f0613eb1887f84a588b5 module/session-catalog +c4609e8a5d473ac1eb4f1366b75819989464cf3c7152e3d90460079205cf2195 module/session-discussion +96b770e79104743ea4476b4cea24aace91d6434491d2c46efd63eb64aa113b3c module/session-store-runtime 4a73a7c32b0dd2ac0a7bd5baf320aef0c7a3cc917eee009e18a4d62edc0fd855 module/setup 32591a4713689552df4bd4533b439a49d9540835cc041e76844157707ea4867b module/setup-runtime 44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools -15478fcfdd7d45e85a32ee6d53f41b3fd3f98faa86141006da7f169e981f98e8 module/skill-commands-runtime -68f1db157af8acec9dc5eb8eb164c53c6c31e527b60c1554e2a94175747f3a8b module/speech-settings +569fd20d35765b74889bfc853eec39b8240e595717fbc21bc0fec0e4b7c3ca2e module/skill-commands-runtime +21942732c49558880a6fcf17acc9a635976f8c037032ce9dd263437e4c2ea217 module/speech-settings a4c6c4173cfdb87988720f7f150e1698bb4b21080864e89fd64587d290544bb0 module/ssrf-policy 0d1a1fdd27724bcb07f2276b015e9dd278cbd4e99dd2ddb628b50a52e533335b module/ssrf-runtime 5501c65f90feec38049ce100cb320b2a629ebf1ad04b21f015a0d44aa1e4c448 module/state-paths @@ -141,11 +141,11 @@ c2aca425088c2bc9a74035f34d8b541354792eb0c44b988e314d59c25ac3aa7f module/telegra aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking 434804a2166f6bf2e872bff0f03f3af850e44dea04b49b1d13b41df872bb71d1 module/text-runtime -b0becceebea0299d42611673b29220962e27a82b953c6d362a64b73e9521e407 module/tool-plugin +3e885b9ecae33cbcdd68f5c209f22f91513d20962cd938772c14f60075374b0a module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results 8c47283ce8caf5008363cd8bc93f5db913f90ba7e2f13b38aa1dbc35cf0083c1 module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -fe5b197995a58d26918c0649fd842c38308a2e1ef5200f93e0376c3ed62506f4 module/webhook-ingress +202beeafe2287f558fe9d749afa4785753c9a1950fcf79b5dd2c52620b318e27 module/webhook-ingress a107b97d3c1bb7494e516760d613950d30dbf57ccaea4b037e2e832ca6115839 module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 18e6c8de76b9..dc3ba556c441 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -724,7 +724,7 @@ Provider plugins that implement both `resolveUsageAuth` and `fetchUsageSnapshot` General embedding providers should declare `contracts.embeddingProviders` for each adapter registered with `api.registerEmbeddingProvider(...)`. Use the general contract for reusable vector generation, including providers consumed by memory search. `contracts.memoryEmbeddingProviders` is deprecated memory-specific compatibility and remains only while existing providers migrate to the generic embedding provider seam. -Worker providers must declare each `api.registerWorkerProvider(...)` id in `contracts.workerProviders`. Core persists durable intent before calling `provision`; providers validate their settings before external allocation, and repeated calls with the same operation id must adopt the same lease. Core also persists that validated settings snapshot and passes it with `leaseId` to `inspect({ leaseId, profile })` and `destroy({ leaseId, profile })`, including after the named profile is changed or removed. Destruction is idempotent, inspection returns the closed `active` / `destroyed` / `unknown` status union, and SSH private-key material is referenced only through `SecretRef`. Provisioned SSH endpoints must also include a public `hostKey` from trusted provisioning output as exactly `algorithm base64`, without a hostname or comment, so core can pin the host before connecting. They may include up to 10 ordered, unique `fallbackPorts`, excluding the primary `port`; core persists those candidates and rotates among them only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed across candidates. Providers that mint dynamic identity refs may implement authoritative `resolveSshIdentity({ leaseId, profile, keyRef })`; providers without it use core's generic secret resolver. An authoritative `unknown` orphans an active local record; after a persisted destroy request it confirms teardown. +Worker providers must declare each `api.registerWorkerProvider(...)` id in `contracts.workerProviders`. Core persists durable intent before calling `provision`; providers validate their settings before external allocation, and repeated calls with the same operation id must adopt the same lease. Core also persists that validated settings snapshot and passes it with `leaseId` to `inspect({ leaseId, profile })` and `destroy({ leaseId, profile })`, including after the named profile is changed or removed. Destruction is idempotent, inspection returns the closed `active` / `destroyed` / `unknown` status union, and SSH private-key material is referenced only through `SecretRef`. Provisioned SSH endpoints must also include a public `hostKey` from trusted provisioning output as exactly `algorithm base64`, without a hostname or comment, so core can pin the host before connecting. They may include up to 10 ordered, unique `fallbackPorts`, excluding the primary `port`; core persists those candidates and rotates among them only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed across candidates. A lease may set `sharedHost: true` when the SSH account also owns unrelated processes; core then avoids host-wide process freezing during workspace reconciliation. Omitted or `false` means a dedicated worker host. Active inspection repeats this fact so core can reconcile provider-owned isolation for leases persisted before the field existed; tunnel startup waits for that first authoritative inspection. Providers that mint dynamic identity refs may implement authoritative `resolveSshIdentity({ leaseId, profile, keyRef })`; providers without it use core's generic secret resolver. An authoritative `unknown` orphans an active local record; after a persisted destroy request it confirms teardown. `contracts.gatewayMethodDispatch` currently accepts `"authenticated-request"`. It is an API hygiene gate for native plugin HTTP routes that intentionally dispatch Gateway control-plane methods in-process, not a sandbox against malicious native plugins. Use it only for tightly reviewed bundled/operator surfaces that already require Gateway HTTP auth. An entitled route remains reachable while Gateway root-work admission is closed only when it also declares `auth: "gateway"` and the route-specific `gatewayRuntimeScopeSurface: "trusted-operator"`; ordinary sibling routes from the same plugin remain behind the admission boundary. This keeps suspension status and resume reachable without granting the whole plugin an admission bypass. Keep parsing and response shaping bounded outside dispatch; substantive or mutating work must go through Gateway method dispatch, which owns admission and scope enforcement. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index fc58edad5064..dd2e890b29ec 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -119,7 +119,7 @@ and external URLs. Registering another provider replaces the current provider. Worker providers must also declare their id in `contracts.workerProviders`. Core persists durable intent before `provision(profile, operationId)`. Providers validate settings before external allocation and throw `WorkerProviderError` for permanent profile rejection. `provision` must adopt the same lease when the operation id repeats. -Core persists the validated profile settings with the lease and supplies that snapshot to `destroy({ leaseId, profile })`, which must be idempotent, and `inspect({ leaseId, profile })`, which returns `active`, `destroyed`, or `unknown`. This lets providers route lifecycle calls after a gateway restart or named-profile removal. SSH endpoints use a `SecretRef` for `keyRef`, never inline key material, and include a `hostKey` from trusted provisioning output as exactly `algorithm base64`, without a hostname or comment. Core pins `hostKey` and never trusts a key from the first connection. Providers may also return up to 10 ordered, unique `fallbackPorts` (integer ports from 1 through 65535, excluding the primary `port`); core validates and persists those advertised candidates for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed across candidates. A provider that mints a dynamic `keyRef` can implement `resolveSshIdentity({ leaseId, profile, keyRef })`; when present, that resolver is authoritative, while providers without it use the configured generic secret resolver. +Core persists the validated profile settings with the lease and supplies that snapshot to `destroy({ leaseId, profile })`, which must be idempotent, and `inspect({ leaseId, profile })`, which returns `active`, `destroyed`, or `unknown`. This lets providers route lifecycle calls after a gateway restart or named-profile removal. SSH endpoints use a `SecretRef` for `keyRef`, never inline key material, and include a `hostKey` from trusted provisioning output as exactly `algorithm base64`, without a hostname or comment. Core pins `hostKey` and never trusts a key from the first connection. Providers may also return up to 10 ordered, unique `fallbackPorts` (integer ports from 1 through 65535, excluding the primary `port`); core validates and persists those advertised candidates for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed across candidates. A lease may set `sharedHost: true` when the SSH account also owns unrelated processes; core then avoids host-wide process freezing during workspace reconciliation. Omitted or `false` means a dedicated worker host. Active inspection repeats this fact so core can reconcile provider-owned isolation for leases persisted before the field existed; tunnel startup waits for that first authoritative inspection. A provider that mints a dynamic `keyRef` can implement `resolveSshIdentity({ leaseId, profile, keyRef })`; when present, that resolver is authoritative, while providers without it use the configured generic secret resolver. Providers with renewable leases can also implement `renew(leaseId)`. `inspect` must throw on transient or indeterminate failures; return `unknown` only for authoritative absence. Core marks an active local record orphaned, or treats the absence as teardown completion after a persisted destroy request. diff --git a/extensions/qa-lab/src/static-ssh-worker-provider.test.ts b/extensions/qa-lab/src/static-ssh-worker-provider.test.ts index abe370288864..4c7c3d7d5c11 100644 --- a/extensions/qa-lab/src/static-ssh-worker-provider.test.ts +++ b/extensions/qa-lab/src/static-ssh-worker-provider.test.ts @@ -32,6 +32,7 @@ describe("QA Lab static-SSH worker provider", () => { expect(provider.id).toBe("static-ssh"); expect(first).toStrictEqual({ leaseId: "static-ssh:operation-123", + sharedHost: true, ssh: { host: "worker.example.test", port: 22, @@ -152,6 +153,7 @@ describe("QA Lab static-SSH worker provider", () => { provider.inspect({ leaseId: "static-ssh:operation-123", profile: PROFILE }), ).resolves.toStrictEqual({ status: "active", + sharedHost: true, }); await expect( provider.inspect({ leaseId: "static-ssh:", profile: PROFILE }), diff --git a/extensions/qa-lab/src/static-ssh-worker-provider.ts b/extensions/qa-lab/src/static-ssh-worker-provider.ts index 4541505b7b37..3d986b4f0eaf 100644 --- a/extensions/qa-lab/src/static-ssh-worker-provider.ts +++ b/extensions/qa-lab/src/static-ssh-worker-provider.ts @@ -77,13 +77,14 @@ export function createStaticSshWorkerProvider(): WorkerProvider { return { leaseId: `${STATIC_SSH_LEASE_PREFIX}${opId}`, ssh: parseStaticSshWorkerSettings(profile), + sharedHost: true, }; }, async inspect({ leaseId }) { const active = leaseId.startsWith(STATIC_SSH_LEASE_PREFIX) && leaseId.length > STATIC_SSH_LEASE_PREFIX.length; - return { status: active ? "active" : "unknown" }; + return active ? { status: "active", sharedHost: true } : { status: "unknown" }; }, // Development-only: a static worker is a shared host, not an isolation boundary. // Destroy releases the logical lease; it does not stop or clean the host. diff --git a/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts b/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts index 4160a8d8b771..0d91cebedccb 100644 --- a/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts +++ b/src/gateway/worker-environments/placement-dispatch-test-fixtures.ts @@ -104,6 +104,7 @@ export function createDispatchEnvironmentFixtures(generation = 1) { profileId: "development", profileSnapshot, provisionOperationId: "provision-1", + sharedHost: false, bootstrapReceipt, teardownTerminalState: null, lastError: null, diff --git a/src/gateway/worker-environments/service-validation.ts b/src/gateway/worker-environments/service-validation.ts index edaeea7a05e6..64460cdfab18 100644 --- a/src/gateway/worker-environments/service-validation.ts +++ b/src/gateway/worker-environments/service-validation.ts @@ -5,7 +5,7 @@ import { redactSensitiveText } from "../../logging/redact.js"; import type { WorkerLease, WorkerLeaseStatus, WorkerSshEndpoint } from "../../plugins/types.js"; import { normalizeWorkerSshEndpoint } from "./store.js"; -export function inspectionStatus(value: unknown): WorkerLeaseStatus["status"] { +export function requireWorkerLeaseStatus(value: unknown): WorkerLeaseStatus { if (!isRecord(value)) { throw new Error("Worker provider returned an invalid inspection result"); } @@ -13,7 +13,16 @@ export function inspectionStatus(value: unknown): WorkerLeaseStatus["status"] { if (status !== "active" && status !== "destroyed" && status !== "unknown") { throw new Error("Worker provider returned an invalid inspection status"); } - return status; + if (status === "active") { + if (value.sharedHost !== undefined && typeof value.sharedHost !== "boolean") { + throw new Error("Worker provider returned an invalid inspection result"); + } + return { status, sharedHost: value.sharedHost === true }; + } + if (value.sharedHost !== undefined) { + throw new Error("Worker provider returned an invalid inspection result"); + } + return { status }; } export function requireWorkerLease(value: unknown): WorkerLease { @@ -21,13 +30,15 @@ export function requireWorkerLease(value: unknown): WorkerLease { !isRecord(value) || typeof value.leaseId !== "string" || !value.leaseId.trim() || - !isRecord(value.ssh) + !isRecord(value.ssh) || + (value.sharedHost !== undefined && typeof value.sharedHost !== "boolean") ) { throw new Error("Worker provider returned an invalid provision result"); } return { leaseId: value.leaseId.trim(), ssh: normalizeWorkerSshEndpoint(value.ssh as WorkerSshEndpoint), + ...(value.sharedHost === true ? { sharedHost: true } : {}), }; } diff --git a/src/gateway/worker-environments/service.test.ts b/src/gateway/worker-environments/service.test.ts index 9f698dafd63e..9ae4f44708d6 100644 --- a/src/gateway/worker-environments/service.test.ts +++ b/src/gateway/worker-environments/service.test.ts @@ -200,6 +200,7 @@ describe("worker environment service", () => { function seedBootstrapping( environmentId: string, install?: WorkerInstallationArtifact["install"], + sharedHost = false, ) { const intent = store.createIntent({ environmentId, @@ -217,12 +218,16 @@ describe("worker environment service", () => { environmentId, from: provisioning.state, to: "bootstrapping", - patch: { leaseId: `lease:${environmentId}`, sshEndpoint: SSH_ENDPOINT }, + patch: { leaseId: `lease:${environmentId}`, sshEndpoint: SSH_ENDPOINT, sharedHost }, }); } - function seedReady(environmentId: string, install?: WorkerInstallationArtifact["install"]) { - const bootstrapping = seedBootstrapping(environmentId, install); + function seedReady( + environmentId: string, + install?: WorkerInstallationArtifact["install"], + sharedHost = false, + ) { + const bootstrapping = seedBootstrapping(environmentId, install, sharedHost); return store.transition({ environmentId, from: bootstrapping.state, @@ -1589,6 +1594,11 @@ describe("worker environment service", () => { }, "SSH fallback ports cannot exceed 10", ], + [ + "invalid shared-host declaration", + { leaseId: "lease-invalid", ssh: SSH_ENDPOINT, sharedHost: "yes" }, + "invalid provision result", + ], ])("keeps %s from a provider retryable", async (_name, result, error) => { const workerService = createService(createProvider({ provision: async () => result as never })); @@ -2024,20 +2034,22 @@ describe("worker environment service", () => { expect(store.get("worker-destroyed-unknown")).toMatchObject({ state: "destroyed" }); }); - it.each([null, { status: "future" }])( - "retains retryable state for malformed inspection result %#", - async (inspection) => { - seedReady("worker-malformed"); - const provider = createProvider({ inspect: async () => inspection as never }); + it.each([ + null, + { status: "future" }, + { status: "active", sharedHost: "yes" }, + { status: "unknown", sharedHost: true }, + ])("retains retryable state for malformed inspection result %#", async (inspection) => { + seedReady("worker-malformed"); + const provider = createProvider({ inspect: async () => inspection as never }); - await createService(provider).reconcileOnce(); + await createService(provider).reconcileOnce(); - expect(store.get("worker-malformed")).toMatchObject({ - state: "ready", - lastError: expect.stringContaining("invalid inspection"), - }); - }, - ); + expect(store.get("worker-malformed")).toMatchObject({ + state: "ready", + lastError: expect.stringContaining("invalid inspection"), + }); + }); it("adopts provider-proven teardown through legal terminal transitions", async () => { seedReady("worker-destroyed-ready"); @@ -2156,7 +2168,7 @@ describe("worker environment service", () => { }); it("projects live tunnel status and fences the tunnel before provider teardown", async () => { - seedReady("worker-tunnel"); + seedReady("worker-tunnel", undefined, true); const order: string[] = []; let tunnelStatus: "stopped" | "connected" = "stopped"; const tunnelManager = { @@ -2198,6 +2210,7 @@ describe("worker environment service", () => { expect.objectContaining({ bundleHash: BUNDLE_HASH, gateway: { host: "127.0.0.1", port: 18_789 }, + sharedHost: true, }), ); expect(workerService.get("worker-tunnel")).toMatchObject({ tunnelStatus: "connected" }); @@ -2210,6 +2223,72 @@ describe("worker environment service", () => { }); }); + it("reconciles shared-host isolation for a persisted lease before tunnel startup", async () => { + seedReady("worker-legacy-shared"); + database.db + .prepare("UPDATE worker_environments SET shared_host = NULL WHERE environment_id = ?") + .run("worker-legacy-shared"); + closeOpenClawStateDatabaseForTest(); + database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }); + store = createWorkerEnvironmentStore({ database, now: () => nowMs }); + const tunnelManager = { + status: () => "stopped" as const, + start: vi.fn(async (request: Parameters[0]) => ({ + environmentId: request.environmentId, + ownerEpoch: request.ownerEpoch, + remoteSocketPath: "/tmp/worker/gateway.sock", + runWorkspaceCommand: vi.fn(), + syncWorkspace: vi.fn(), + stop: async () => {}, + })), + stop: vi.fn(async () => {}), + stopAll: vi.fn(async () => {}), + } as unknown as WorkerTunnelManager; + let inspectionFails = true; + const provider = createProvider({ + inspect: async () => { + if (inspectionFails) { + throw new Error("provider unavailable"); + } + return { status: "active", sharedHost: true }; + }, + }); + const workerService = createService(provider, { tunnelManager }); + + expect(store.get("worker-legacy-shared")?.sharedHost).toBeNull(); + await workerService.reconcileOnce(); + await expect( + workerService.startTunnel({ environmentId: "worker-legacy-shared", ownerEpoch: 1 }), + ).rejects.toThrow("isolation is not reconciled"); + expect(tunnelManager.start).not.toHaveBeenCalled(); + inspectionFails = false; + await workerService.reconcileOnce(); + expect(store.get("worker-legacy-shared")?.sharedHost).toBe(true); + await workerService.startTunnel({ environmentId: "worker-legacy-shared", ownerEpoch: 1 }); + expect(tunnelManager.start).toHaveBeenCalledWith(expect.objectContaining({ sharedHost: true })); + }); + + it("fences an existing tunnel before changing its shared-host isolation", async () => { + seedReady("worker-isolation-change"); + const stop = vi.fn(async () => { + expect(store.get("worker-isolation-change")?.sharedHost).toBe(false); + }); + const tunnelManager = { + status: () => "connected" as const, + start: vi.fn(), + stop, + stopAll: vi.fn(async () => {}), + } as unknown as WorkerTunnelManager; + const provider = createProvider({ + inspect: async () => ({ status: "active", sharedHost: true }), + }); + + await createService(provider, { tunnelManager }).reconcileOnce(); + + expect(stop).toHaveBeenCalledWith("worker-isolation-change"); + expect(store.get("worker-isolation-change")?.sharedHost).toBe(true); + }); + it("fences a draining tunnel before reporting an unavailable provider", async () => { seedReady("worker-provider-missing"); const tunnelManager = { diff --git a/src/gateway/worker-environments/service.ts b/src/gateway/worker-environments/service.ts index 58ae9b2d21c9..3f3a5a160bcd 100644 --- a/src/gateway/worker-environments/service.ts +++ b/src/gateway/worker-environments/service.ts @@ -58,7 +58,7 @@ import { import type { WorkerLiveEventApplicationResult, WorkerLiveEventReceiver } from "./live-events.js"; import { boundedWorkerError as boundedError, - inspectionStatus, + requireWorkerLeaseStatus, requireWorkerLease, } from "./service-validation.js"; import type { WorkerEnvironmentState } from "./state.js"; @@ -520,6 +520,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService return move(destroying, "failed", { leaseId: null, sshEndpoint: null, + sharedHost: false, lastError: destroying.lastError ?? "Worker bootstrap failed after provider teardown", }); }; @@ -631,7 +632,11 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService throw serviceError("provider_failure", `Worker provider operation failed: ${detail}`); } // A timeout can happen after allocation; retain the same operation id for safe replay. - const patch = { leaseId: lease.leaseId, sshEndpoint: lease.ssh }; + const patch = { + leaseId: lease.leaseId, + sshEndpoint: lease.ssh, + sharedHost: lease.sharedHost === true, + }; const bootstrapping = move(record, "bootstrapping", patch); if (record.destroyRequestedAtMs !== null) { return bootstrapping; @@ -789,17 +794,18 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService } return; } - const status = await callProvider(record.environmentId, () => + const inspection = await callProvider(record.environmentId, () => provider.inspect(lifecycleLease(record, leaseId)), ) - .then(inspectionStatus) + .then(requireWorkerLeaseStatus) .catch((error: unknown) => { saveError(record, error); return undefined; }); - if (!status) { + if (!inspection) { return; } + const { status } = inspection; const teardownExpected = record.destroyRequestedAtMs !== null || record.state === "destroying"; if (status === "destroyed" || (status === "unknown" && teardownExpected)) { const requested = @@ -820,6 +826,18 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService move(draining, "orphaned", { lastError: ORPHANED_LEASE_ERROR }); return; } + const inspectedSharedHost = inspection.sharedHost === true; + if (record.sharedHost !== null && record.sharedHost !== inspectedSharedHost) { + // Workspace actions capture isolation at tunnel creation. Fence the old actions before + // committing a provider-owned change so no reconciliation can use stale host scope. + await tunnels?.stop(record.environmentId); + } + record = store.reconcileSharedHost({ + environmentId: record.environmentId, + state: record.state, + leaseId, + sharedHost: inspectedSharedHost, + }); if (record.destroyRequestedAtMs !== null) { await finishDestroy(record, provider).catch(() => undefined); return; @@ -1087,6 +1105,12 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService ) { throw serviceError("invalid_state", `Cannot start tunnel in state: ${record.state}`); } + if (record.sharedHost === null) { + throw serviceError( + "provider_failure", + "Worker lease isolation is not reconciled; retry after provider inspection", + ); + } const credential = store.getCredential(request.environmentId); if ( !credential || @@ -1107,6 +1131,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService bundleHash: record.bootstrapReceipt.bundleHash, gateway, ssh: record.sshEndpoint, + sharedHost: record.sharedHost, resolveIdentity: identityResolverFor(record, provider, record.leaseId), }); }); diff --git a/src/gateway/worker-environments/store.test.ts b/src/gateway/worker-environments/store.test.ts index b4700fba6eed..5bd65ea62f85 100644 --- a/src/gateway/worker-environments/store.test.ts +++ b/src/gateway/worker-environments/store.test.ts @@ -184,7 +184,7 @@ describe("worker environment store", () => { environmentId: "worker-1", from: "provisioning", to: "bootstrapping", - patch: { leaseId: "lease-1", sshEndpoint: SSH_ENDPOINT }, + patch: { leaseId: "lease-1", sshEndpoint: SSH_ENDPOINT, sharedHost: true }, }); nowMs = 1_030; store.transition({ @@ -198,6 +198,7 @@ describe("worker environment store", () => { store = createWorkerEnvironmentStore({ database, now: () => nowMs }); expect(store.get("worker-1")).toMatchObject({ sshEndpoint: SSH_ENDPOINT, + sharedHost: true, bootstrapReceipt: { ...BOOTSTRAP_RECEIPT, protocolFeatures: ["model-proxy-v1", "workspace-sync-v1"], diff --git a/src/gateway/worker-environments/store.ts b/src/gateway/worker-environments/store.ts index 0776ee2f5132..5600fecb073f 100644 --- a/src/gateway/worker-environments/store.ts +++ b/src/gateway/worker-environments/store.ts @@ -43,6 +43,7 @@ type RecordIdentity = { environmentId: string; providerId: string; profileId: st type RecordBase = RecordIdentity & { profileSnapshot: WorkerEnvironmentProfileSnapshot; provisionOperationId: string; + sharedHost: boolean | null; bootstrapReceipt: WorkerEnvironmentBootstrapReceipt | null; ownerEpoch: number; teardownTerminalState: WorkerEnvironmentTeardownTerminalState | null; @@ -67,6 +68,7 @@ export class WorkerSessionAlreadyAttachedError extends Error { export type WorkerEnvironmentTransitionPatch = { leaseId?: string | null; sshEndpoint?: WorkerEnvironmentSshEndpoint | null; + sharedHost?: boolean; bootstrapReceipt?: WorkerEnvironmentBootstrapReceipt; attachedSessionIds?: readonly string[]; lastError?: string | null; @@ -369,6 +371,7 @@ function fromRow(row: Row, fallbackPorts: readonly number[]): WorkerEnvironmentR profileId: row.profile_id, profileSnapshot: JSON.parse(row.profile_snapshot_json) as WorkerEnvironmentProfileSnapshot, provisionOperationId: row.provision_operation_id, + sharedHost: row.shared_host === null ? null : row.shared_host === 1, leaseId: row.lease_id, sshEndpoint: endpointFrom(row, fallbackPorts), bootstrapReceipt: bootstrapReceiptFrom(row), @@ -719,6 +722,7 @@ export function createWorkerEnvironmentStore( "provision operation id", ), lease_id: null, + shared_host: null, ssh_host: null, ssh_port: null, ssh_user: null, @@ -747,6 +751,30 @@ export function createWorkerEnvironmentStore( findCredentialByHash(read(), normalizeCredentialHash(credentialHash)), list: (): WorkerEnvironmentRecord[] => listRows(read(), false), listForReconcile: (): WorkerEnvironmentRecord[] => listRows(read(), true), + reconcileSharedHost(input: { + environmentId: string; + state: WorkerEnvironmentState; + leaseId: string; + sharedHost: boolean; + }): WorkerEnvironmentRecord { + const environmentId = required(input.environmentId, "id"); + const leaseId = required(input.leaseId, "lease id"); + return write((db) => { + const current = getRequired(db, environmentId); + if (current.state !== input.state || current.leaseId !== leaseId) { + throw new Error(`Worker environment ${environmentId} lease changed during inspection`); + } + if (current.sharedHost === input.sharedHost) { + return current; + } + // Provider inspection owns facts that may predate their durable column. Persist an + // explicit value before tunnel startup so upgraded leases cannot keep stale isolation. + return update(db, environmentId, current.state, { + shared_host: input.sharedHost ? 1 : 0, + updated_at_ms: now(), + }); + }); + }, requestDestroy(input: { environmentId: string; state: WorkerEnvironmentState; @@ -829,6 +857,7 @@ export function createWorkerEnvironmentStore( : patch.sshEndpoint === null ? null : normalizeWorkerSshEndpoint(patch.sshEndpoint); + const sharedHost = leaseId === null ? null : (patch.sharedHost ?? current.sharedHost); const acceptsBootstrapReceipt = from === "bootstrapping" && to === "ready"; if (patch.bootstrapReceipt !== undefined && !acceptsBootstrapReceipt) { throw new Error("Bootstrap receipt can only be recorded when a worker becomes ready"); @@ -906,6 +935,7 @@ export function createWorkerEnvironmentStore( : current.ownerEpoch; updateRow(db, environmentId, from, { lease_id: leaseId, + shared_host: sharedHost === null ? null : sharedHost ? 1 : 0, ssh_host: sshEndpoint?.host ?? null, ssh_port: sshEndpoint?.port ?? null, ssh_user: sshEndpoint?.user ?? null, diff --git a/src/gateway/worker-environments/tunnel.test-support.ts b/src/gateway/worker-environments/tunnel.test-support.ts index 0fe0c823d69c..46fb711bceaf 100644 --- a/src/gateway/worker-environments/tunnel.test-support.ts +++ b/src/gateway/worker-environments/tunnel.test-support.ts @@ -15,7 +15,7 @@ import type { WorkerWorkspaceReconciliationJournal, WorkerWorkspaceReconciliationJournalAdapter, } from "./workspace-reconcile.js"; -import { stableWorkerPathComponent } from "./workspace-sync.js"; +import { stableWorkerPathComponent } from "./workspace-sync-helpers.js"; export function waitForFast( callback: () => T | Promise, @@ -368,12 +368,14 @@ export function startTestTunnel( environmentId: string, ownerEpoch: number, ssh: WorkerSshEndpoint = SSH, + sharedHost = false, ) { return manager.start({ environmentId, ownerEpoch, bundleHash: BUNDLE_HASH, ssh, + sharedHost, gateway: { host: "127.0.0.1", port: 18789 }, resolveIdentity, }); @@ -385,12 +387,19 @@ export async function startConnectedTunnel( ownerEpoch: number, options: { ssh?: WorkerSshEndpoint; + sharedHost?: boolean; manager?: Omit; beforeReady?: (start: TunnelTestFake["starts"][number]) => void; } = {}, ) { const manager = createWorkerTunnelManager({ ...options.manager, runner: fake.runner }); - const starting = startTestTunnel(manager, environmentId, ownerEpoch, options.ssh); + const starting = startTestTunnel( + manager, + environmentId, + ownerEpoch, + options.ssh, + options.sharedHost, + ); await waitForStarts(fake.starts, 1); const start = fake.starts[0]!; options.beforeReady?.(start); diff --git a/src/gateway/worker-environments/tunnel.test.ts b/src/gateway/worker-environments/tunnel.test.ts index bded2000cb45..e019eb7014b1 100644 --- a/src/gateway/worker-environments/tunnel.test.ts +++ b/src/gateway/worker-environments/tunnel.test.ts @@ -70,6 +70,35 @@ describe("worker tunnel manager", () => { } }); + it("passes shared-host isolation to initial and renewal quiescence commands", async () => { + const nonce = "b".repeat(32); + const fake = fakeRunner((argv) => { + const remoteCommand = argv.at(-1) ?? ""; + if (remoteCommand.includes('process.stdout.write("quiesced "')) { + return success(`quiesced ${nonce}\n`); + } + if (remoteCommand.includes('process.stdout.write("renewed "')) { + return success(`renewed ${nonce}\n`); + } + return undefined; + }); + const { handle } = await startConnectedTunnel(fake, "worker:shared-quiescence", 3, { + sharedHost: true, + }); + + const quiescence = await handle.quiesceWorkspace("/home/worker/workspace"); + await quiescence.assertActive(); + const quiescenceCommands = fake.runs.filter((entry) => + entry.argv.at(-1)?.includes("workspace quiescence"), + ); + expect(quiescenceCommands).toHaveLength(2); + expect(quiescenceCommands.every((entry) => entry.argv.at(-1)?.includes("shared-host"))).toBe( + true, + ); + await quiescence.resume(); + await handle.stop(); + }); + it("reconnects with capped backoff after unexpected exits and failed attempts", async () => { const fake = fakeRunner(); const delays: number[] = []; diff --git a/src/gateway/worker-environments/tunnel.ts b/src/gateway/worker-environments/tunnel.ts index 1d58ba8e3aef..4a78630e28b6 100644 --- a/src/gateway/worker-environments/tunnel.ts +++ b/src/gateway/worker-environments/tunnel.ts @@ -28,7 +28,8 @@ import { workerSshProcessError, WORKER_TUNNEL_READY_MARKER, } from "./tunnel-ssh-runner.js"; -import { createWorkerWorkspaceActions, stableWorkerPathComponent } from "./workspace-sync.js"; +import { stableWorkerPathComponent } from "./workspace-sync-helpers.js"; +import { createWorkerWorkspaceActions } from "./workspace-sync.js"; export type { WorkerTunnelHandle } from "./tunnel-contract.js"; const REMOTE_SOCKET_NAME = "gateway.sock"; @@ -80,6 +81,7 @@ type WorkerTunnelStartRequest = WorkerTunnelRequest & { bundleHash: string; gateway: { host: "127.0.0.1" | "::1"; port: number }; ssh: WorkerSshEndpoint; + sharedHost?: boolean; resolveIdentity: WorkerSshIdentityResolver; }; @@ -88,6 +90,7 @@ type TunnelEntry = { environmentId: string; ownerEpoch: number; gateway: WorkerTunnelStartRequest["gateway"]; + sharedHost: boolean; remoteDirectory: string; remoteSocketPath: string; abortController: AbortController; @@ -226,6 +229,7 @@ export function createWorkerTunnelManager(options: WorkerTunnelManagerOptions = remoteSocketPath: entry.remoteSocketPath, ...createWorkerWorkspaceActions({ environmentId: entry.environmentId, + sharedHost: entry.sharedHost, ownerSignal: entry.abortController.signal, isConnected: () => isCurrent(entry) && entry.status === "connected", getPrepared: () => entry.prepared, @@ -419,6 +423,7 @@ export function createWorkerTunnelManager(options: WorkerTunnelManagerOptions = bundleHash: request.bundleHash, ownerEpoch: request.ownerEpoch, gateway: request.gateway, + sharedHost: request.sharedHost === true, remoteDirectory, remoteSocketPath: `${remoteDirectory}/${REMOTE_SOCKET_NAME}`, abortController: new AbortController(), diff --git a/src/gateway/worker-environments/worker-turn-launcher.test.ts b/src/gateway/worker-environments/worker-turn-launcher.test.ts index 9abf53ec04b6..e2f5a2f08ff4 100644 --- a/src/gateway/worker-environments/worker-turn-launcher.test.ts +++ b/src/gateway/worker-environments/worker-turn-launcher.test.ts @@ -255,6 +255,7 @@ describe("worker turn launcher", () => { profileId: "development", profileSnapshot: { settings: { region: "test" } }, provisionOperationId: "provision-worker-turn", + sharedHost: false, bootstrapReceipt: { bundleHash: BUNDLE_HASH, openclawVersion: "2026.7.2", diff --git a/src/gateway/worker-environments/workspace-quiescence-scripts.ts b/src/gateway/worker-environments/workspace-quiescence-scripts.ts index b04ae71bbec9..81936672d20e 100644 --- a/src/gateway/worker-environments/workspace-quiescence-scripts.ts +++ b/src/gateway/worker-environments/workspace-quiescence-scripts.ts @@ -72,6 +72,7 @@ function parseLease(raw, expectedNonce, options = {}) { !lease || lease.version !== 1 || lease.nonce !== expectedNonce || + (lease.sharedHost !== undefined && typeof lease.sharedHost !== "boolean") || !Array.isArray(lease.processes) || lease.processes.length > 4096 || lease.processes.some((entry) => !validProcessReference(entry)) || @@ -114,6 +115,9 @@ const nonce = crypto.randomBytes(16).toString("hex"); const leasePath = path.join(leaseDirectory, workspaceKey + "." + nonce + ".json"); const watchdogTimeoutMs = Number(process.argv[2] || 12 * 60 * 1000); if (!Number.isSafeInteger(watchdogTimeoutMs) || watchdogTimeoutMs < 1) throw new Error("invalid watchdog timeout"); +const isolationMode = process.argv[3] || "dedicated"; +if (isolationMode !== "dedicated" && isolationMode !== "shared-host") throw new Error("invalid workspace quiescence isolation mode"); +const sharedHost = isolationMode === "shared-host"; ${REMOTE_QUIESCENCE_PS_JS} ${REMOTE_QUIESCENCE_LEASE_JS} const frozen = new Map(); @@ -122,6 +126,7 @@ function writeLease(expiresAtMs = Date.now() + watchdogTimeoutMs) { persistLease(leasePath, { version: 1, nonce, + sharedHost, processes: [...frozen].map(([pid, start]) => ({ pid, start })), watchdog: watchdogReference, expiresAtMs, @@ -179,7 +184,14 @@ watchdogReference = { pid: watchdog.pid, start: watchdogStart }; writeLease(); let quietScans = 0; try { - for (let attempt = 0; attempt < 250 && quietScans < 3; attempt += 1) { + if (sharedHost) { + // The worker has already published its terminal result. Manifest stability fences around + // transfer, apply, renewal, and publication reject later writes; only the uid-wide SIGSTOP + // sweep is skipped because this provider explicitly declared processes the lease does not own. + process.stderr.write("workspace quiescence: shared host declared; skipping process freeze sweep\n"); + quietScans = 3; + } + for (let attempt = 0; !sharedHost && attempt < 250 && quietScans < 3; attempt += 1) { const candidates = quiescenceCandidates( processes(), uid, @@ -286,11 +298,14 @@ const root = fs.realpathSync(process.argv[1]); const nonce = process.argv[2]; const timeoutMs = Number(process.argv[3] || 12 * 60 * 1000); const validationMode = process.argv[4] || "final"; +const isolationMode = process.argv[5] || "dedicated"; if (typeof process.getuid !== "function") throw new Error("workspace quiescence requires POSIX"); const uid = process.getuid(); if (!/^[a-f0-9]{32}$/.test(nonce || "")) throw new Error("invalid workspace quiescence nonce"); if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10 * 1000) throw new Error("invalid watchdog timeout"); if (validationMode !== "heartbeat" && validationMode !== "final") throw new Error("invalid workspace quiescence validation mode"); +if (isolationMode !== "dedicated" && isolationMode !== "shared-host") throw new Error("invalid workspace quiescence isolation mode"); +const sharedHost = isolationMode === "shared-host"; const leasePath = path.join(os.homedir(), ".openclaw-worker", "quiescence", crypto.createHash("sha256").update(root).digest("hex") + "." + nonce + ".json"); ${REMOTE_QUIESCENCE_PS_JS} ${REMOTE_QUIESCENCE_LEASE_JS} @@ -299,6 +314,7 @@ const input = parseLease(fs.readFileSync(leasePath, "utf8"), nonce, { minimumRemainingMs: 5000, errorMessage: "workspace quiescence lease is no longer active", }); +if ((input.sharedHost === true) !== sharedHost) throw new Error("workspace quiescence isolation mode changed"); function writeLease(processes, expiresAtMs) { // renewalQueue is the nonce's only writer; the watchdog only reads this lease. persistLease(leasePath, { ...input, processes, expiresAtMs }, (current) => { @@ -328,7 +344,7 @@ for (const entry of input.processes) { if (status.state && !status.state.startsWith("T")) throw new Error("workspace quiescence process resumed unexpectedly"); } refreshLease(input.processes); -if (validationMode === "final") { +if (validationMode === "final" && !sharedHost) { const frozen = new Map(input.processes.map((entry) => [entry.pid, entry.start])); let quietScans = 0; const sleeper = new Int32Array(new SharedArrayBuffer(4)); diff --git a/src/gateway/worker-environments/workspace-sync-helpers.ts b/src/gateway/worker-environments/workspace-sync-helpers.ts index c762c8982d3b..f2138f58287e 100644 --- a/src/gateway/worker-environments/workspace-sync-helpers.ts +++ b/src/gateway/worker-environments/workspace-sync-helpers.ts @@ -19,6 +19,7 @@ export const WORKER_WORKSPACE_RSYNC_DESTINATION = "openclaw-rsync-destination"; export type WorkerWorkspaceActionsOptions = { environmentId: string; + sharedHost?: boolean; ownerSignal: AbortSignal; isConnected: () => boolean; getPrepared: () => PreparedWorkerSsh | undefined; diff --git a/src/gateway/worker-environments/workspace-sync-scripts.test.ts b/src/gateway/worker-environments/workspace-sync-scripts.test.ts index d598fc3d3a83..face001ef958 100644 --- a/src/gateway/worker-environments/workspace-sync-scripts.test.ts +++ b/src/gateway/worker-environments/workspace-sync-scripts.test.ts @@ -66,14 +66,24 @@ async function fixture() { }; } -async function quiesce(input: Awaited>) { +async function quiesce(input: Awaited>, sharedHost = false) { const result = await runCommandWithTimeout( - [process.execPath, "-e", REMOTE_WORKSPACE_QUIESCE_JS, input.workspace, "10000"], + [ + process.execPath, + "-e", + REMOTE_WORKSPACE_QUIESCE_JS, + input.workspace, + "10000", + sharedHost ? "shared-host" : "dedicated", + ], { timeoutMs: 10_000, baseEnv: input.env }, ); expect(result.code).toBe(0); const match = /^quiesced ([a-f0-9]{32})\n$/u.exec(result.stdout); expect(match).not.toBeNull(); + if (sharedHost) { + expect(result.stderr).toContain("shared host declared; skipping process freeze sweep"); + } return match![1]!; } @@ -90,9 +100,22 @@ async function resume(input: Awaited>, nonce: string) expect(result.code).toBe(0); } -async function renew(input: Awaited>, nonce: string) { +async function renew( + input: Awaited>, + nonce: string, + sharedHost = false, +) { const result = await runCommandWithTimeout( - [process.execPath, "-e", REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS, input.workspace, nonce, "20000"], + [ + process.execPath, + "-e", + REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS, + input.workspace, + nonce, + "20000", + "final", + sharedHost ? "shared-host" : "dedicated", + ], { timeoutMs: 10_000, baseEnv: input.env }, ); expect(result.code).toBe(0); @@ -203,6 +226,36 @@ describe("remote workspace quiescence scripts", () => { } }); + it("keeps unrelated same-uid processes running on a declared shared host", async () => { + const input = await fixture(); + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); + expect(child.pid).toBeDefined(); + await fs.writeFile(input.extraProcessPath, `${child.pid}\n`); + + let nonce: string | undefined; + try { + nonce = await quiesce(input, true); + await renew(input, nonce, true); + const lease = JSON.parse( + await fs.readFile(leasePath(input.home, input.workspace, nonce), "utf8"), + ) as { processes: Array<{ pid: number }>; sharedHost: boolean }; + expect(lease).toMatchObject({ processes: [], sharedHost: true }); + expect(() => process.kill(child.pid!, 0)).not.toThrow(); + } finally { + if (nonce) { + await resume(input, nonce); + } + child.kill("SIGCONT"); + child.kill("SIGTERM"); + if (child.exitCode === null) { + await once(child, "exit"); + } + await fs.rm(input.extraProcessPath, { force: true }); + } + }); + it("fails closed when the watchdog lease no longer exists", async () => { const input = await fixture(); const nonce = await quiesce(input); diff --git a/src/gateway/worker-environments/workspace-sync-tunnel.test.ts b/src/gateway/worker-environments/workspace-sync-tunnel.test.ts index aabda52a7bc4..a5ce3a82b695 100644 --- a/src/gateway/worker-environments/workspace-sync-tunnel.test.ts +++ b/src/gateway/worker-environments/workspace-sync-tunnel.test.ts @@ -26,7 +26,7 @@ import { } from "./tunnel.test-support.js"; import { rsyncArgvPort, sshArgvPort } from "./worker-ssh-argv.test-support.js"; import { parseWorkerWorkspaceManifest } from "./workspace-reconcile.js"; -import { stableWorkerPathComponent } from "./workspace-sync.js"; +import { stableWorkerPathComponent } from "./workspace-sync-helpers.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); diff --git a/src/gateway/worker-environments/workspace-sync.ts b/src/gateway/worker-environments/workspace-sync.ts index be89d4a020f9..9f094b6dd279 100644 --- a/src/gateway/worker-environments/workspace-sync.ts +++ b/src/gateway/worker-environments/workspace-sync.ts @@ -18,6 +18,11 @@ import { recoverAcceptedWorkspacePublication, } from "./workspace-accepted-sync.js"; import { DERIVED_WORKSPACE_RSYNC_EXCLUDES } from "./workspace-path-exclusions.js"; +import { + REMOTE_WORKSPACE_QUIESCE_JS, + REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS, + REMOTE_WORKSPACE_RESUME_JS, +} from "./workspace-quiescence-scripts.js"; import { applyStagedWorkerWorkspace, assertWorkspaceMatchesManifest, @@ -53,12 +58,6 @@ import { type WorkerWorkspaceActionsOptions, } from "./workspace-sync-helpers.js"; import { createGitTransferList, runLocalCommandToFile } from "./workspace-sync-local.js"; -export { stableWorkerPathComponent } from "./workspace-sync-helpers.js"; -import { - REMOTE_WORKSPACE_QUIESCE_JS, - REMOTE_WORKSPACE_RENEW_QUIESCENCE_JS, - REMOTE_WORKSPACE_RESUME_JS, -} from "./workspace-quiescence-scripts.js"; import { REMOTE_GIT_WORKSPACE_RETRY_RESET_JS, REMOTE_GIT_WORKSPACE_SETUP_SCRIPT, @@ -153,6 +152,7 @@ export function createWorkerWorkspaceActions( REMOTE_WORKSPACE_QUIESCE_JS, remoteWorkspaceDir, String(WORKSPACE_QUIESCENCE_TIMEOUT_MS), + options.sharedHost === true ? "shared-host" : "dedicated", ], }); if (!success(result)) { @@ -181,6 +181,7 @@ export function createWorkerWorkspaceActions( nonce, String(WORKSPACE_QUIESCENCE_TIMEOUT_MS), validationMode, + options.sharedHost === true ? "shared-host" : "dedicated", ], }); if (!success(renewedResult)) { diff --git a/src/plugins/capability-provider.types.ts b/src/plugins/capability-provider.types.ts index e2e6d3470e19..d775e4f11c10 100644 --- a/src/plugins/capability-provider.types.ts +++ b/src/plugins/capability-provider.types.ts @@ -82,11 +82,17 @@ export type WorkerSshIdentityRequest = { export type WorkerLease = { leaseId: string; ssh: WorkerSshEndpoint; + /** The SSH account also owns processes unrelated to this worker lease. */ + sharedHost?: boolean; }; /** Authoritative inspection result for an already-known worker lease. */ export type WorkerLeaseStatus = - | { status: "active" } + | { + status: "active"; + /** Explicit provider fact used to reconcile leases persisted before this metadata existed. */ + sharedHost?: boolean; + } | { status: "destroyed" } | { status: "unknown" }; diff --git a/src/state/openclaw-state-db-maintenance.ts b/src/state/openclaw-state-db-maintenance.ts index 992f78a20c13..4287b634eb1f 100644 --- a/src/state/openclaw-state-db-maintenance.ts +++ b/src/state/openclaw-state-db-maintenance.ts @@ -32,6 +32,7 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [ "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; diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 1a460a0cdc15..0ff63f1c2ae2 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -363,6 +363,7 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void { "owner_epoch INTEGER NOT NULL DEFAULT 0 CHECK (owner_epoch >= 0)", ); ensureColumn(db, "worker_environments", "ssh_host_key TEXT"); + ensureColumn(db, "worker_environments", "shared_host INTEGER CHECK (shared_host IN (0, 1))"); ensureColumn(db, "worker_workspace_pending_results", "staged_result_ref TEXT"); ensureColumn( db, diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 128f4484f3af..6d851e163888 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -1431,6 +1431,7 @@ export interface WorkerEnvironments { profile_snapshot_json: string; provider_id: string; provision_operation_id: string; + shared_host: number | null; ssh_host: string | null; ssh_host_key: string | null; ssh_key_ref_json: string | null; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index bacd10d0440d..218340e87ed0 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1846,6 +1846,7 @@ CREATE TABLE IF NOT EXISTS worker_environments ( profile_snapshot_json TEXT NOT NULL, provision_operation_id TEXT NOT NULL UNIQUE, lease_id TEXT, + shared_host INTEGER CHECK (shared_host IN (0, 1)), ssh_host TEXT, ssh_port INTEGER CHECK (ssh_port IS NULL OR (ssh_port >= 1 AND ssh_port <= 65535)), ssh_user TEXT, diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index b83fd5d95c74..d2f1d96bc529 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -3134,13 +3134,17 @@ NODE expect(writerStep.with.key).toContain("github.run_attempt"); expect(writerStep.with.key).not.toContain("pull_request"); expect(writerStep.with["restore-keys"]).toContain("**/tsconfig*.json"); + expect(writerStep.with.key).toContain("src/state/*.sql"); + expect(writerStep.with["restore-keys"]).toContain("src/state/*.sql"); expect(writerStep.with.key).toContain("!**/node_modules/**"); expect(writerStep.with["restore-keys"]).toContain("!**/node_modules/**"); expect(readerStep.uses).toBe(CACHE_V5); expect(readerStep.if).toContain("inputs.save-vitest-fs-cache != 'true'"); expect(readerStep.with["restore-keys"]).toBe(writerStep.with["restore-keys"]); expect(readerStep.with.key).toContain("!**/node_modules/**"); + expect(readerStep.with.key).toContain("src/state/*.sql"); expect(configureStep.env.CACHE_GENERATION).toContain("!**/node_modules/**"); + expect(configureStep.env.CACHE_GENERATION).toContain("src/state/*.sql"); expect(configureStep.run).toContain("OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=$cache_root"); expect(configureStep.run).toContain(".openclaw-transform-generation"); expect(configureStep.run).not.toContain("protected Vitest transform seed");