fix(workers): skip shared-host quiescence sweeps (#120969)

* fix(workers): skip shared-host quiescence sweeps

Refs #120952.

* test(workers): update shared-host fixtures

Refs #120952.

* fix(workers): reconcile shared-host lease metadata

* fix(workers): fence unknown lease isolation

* chore(plugin-sdk): refresh API baseline

* fix(workers): fence tunnel isolation updates

* docs(workers): clarify shared-host final fences

* ci: invalidate Vitest cache for state schemas

* refactor(workers): import stableWorkerPathComponent from its defining module

workspace-sync.ts crossed the 700-line lint budget by one; drop its
re-export and point consumers at workspace-sync-helpers directly.
This commit is contained in:
Peter Steinberger
2026-08-09 04:34:39 -07:00
committed by GitHub
parent 262cf17baf
commit b869d5e73f
27 changed files with 375 additions and 96 deletions
+5 -5
View File
@@ -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: |
+46 -46
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
@@ -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 }),
@@ -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.
@@ -104,6 +104,7 @@ export function createDispatchEnvironmentFixtures(generation = 1) {
profileId: "development",
profileSnapshot,
provisionOperationId: "provision-1",
sharedHost: false,
bootstrapReceipt,
teardownTerminalState: null,
lastError: null,
@@ -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 } : {}),
};
}
+95 -16
View File
@@ -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<WorkerTunnelManager["start"]>[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 = {
+30 -5
View File
@@ -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),
});
});
@@ -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"],
+30
View File
@@ -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,
@@ -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<T>(
callback: () => T | Promise<T>,
@@ -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<TunnelManagerOptions, "runner">;
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);
@@ -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[] = [];
+6 -1
View File
@@ -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(),
@@ -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",
@@ -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));
@@ -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;
@@ -66,14 +66,24 @@ async function fixture() {
};
}
async function quiesce(input: Awaited<ReturnType<typeof fixture>>) {
async function quiesce(input: Awaited<ReturnType<typeof fixture>>, 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<ReturnType<typeof fixture>>, nonce: string)
expect(result.code).toBe(0);
}
async function renew(input: Awaited<ReturnType<typeof fixture>>, nonce: string) {
async function renew(
input: Awaited<ReturnType<typeof fixture>>,
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);
@@ -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);
@@ -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)) {
+7 -1
View File
@@ -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" };
@@ -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;
@@ -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,
+1
View File
@@ -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;
+1
View File
@@ -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,
+4
View File
@@ -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");