From 2cc19d158948925f83259438c0e475b08b2a935f Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:03:29 +0300 Subject: [PATCH 01/10] fix: regenerate broken lockfile, remove dead import, enable strict unused checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package-lock.json had a corrupted empty version string for the axios override that broke `npm ci` (Invalid Version error). Regenerated from package.json — now resolves cleanly with 0 vulnerabilities. - Removed unused `IncomingMessage` import from src/agent/server.ts. - Enabled `noUnusedLocals` and `noUnusedParameters` in tsconfig.server.json to prevent future dead imports from being committed. The codebase already passes with these flags after the import fix. --- package-lock.json | 531 +++++++++++++++++++++++++++++++------------ src/agent/server.ts | 2 +- tsconfig.server.json | 2 + 3 files changed, 387 insertions(+), 148 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad484a7..98f521b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,62 +57,6 @@ "zustand": "5.0.3" } }, - "node_modules/@base-org/account/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@base-org/account/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@base-org/account/node_modules/ox/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@coinbase/cdp-sdk": { "version": "1.55.0", "resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.55.0.tgz", @@ -176,12 +120,6 @@ } } }, - "node_modules/@coinbase/cdp-sdk/node_modules/axios": { - "optional": true - }, - "node_modules/@coinbase/cdp-sdk/node_modules/axios-retry": { - "optional": true - }, "node_modules/@coinbase/cdp-sdk/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -688,12 +626,12 @@ } }, "node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", + "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "1.7.0" }, "engines": { "node": "^14.21.3 || >=16" @@ -702,10 +640,10 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -714,6 +652,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.142.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", @@ -807,6 +758,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@reown/appkit-controllers/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/core": { "version": "2.23.7", "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.23.7.tgz", @@ -1060,6 +1023,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@reown/appkit-utils/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@reown/appkit-utils/node_modules/@walletconnect/core": { "version": "2.23.7", "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.23.7.tgz", @@ -1257,6 +1232,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@reown/appkit/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@reown/appkit/node_modules/@walletconnect/core": { "version": "2.23.7", "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.23.7.tgz", @@ -1719,6 +1706,33 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@scure/bip39": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", @@ -1732,6 +1746,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@solana-program/system": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", @@ -3252,21 +3278,6 @@ "uint8arrays": "^3.0.0" } }, - "node_modules/@walletconnect/relay-auth/node_modules/@noble/curves": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", - "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@walletconnect/relay-auth/node_modules/@noble/hashes": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", @@ -3388,6 +3399,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@walletconnect/utils/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@walletconnect/utils/node_modules/ox": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/ox/-/ox-0.9.3.tgz", @@ -3468,9 +3491,9 @@ ] }, "node_modules/abitype": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", - "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/wevm" @@ -3501,6 +3524,19 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3538,18 +3574,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3571,7 +3595,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -3583,6 +3607,32 @@ "node": ">=8.0.0" } }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -3836,7 +3886,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4003,7 +4053,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4139,7 +4189,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4303,24 +4353,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -4355,11 +4387,32 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4376,7 +4429,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4386,7 +4439,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -4546,7 +4599,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4590,6 +4643,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -4678,6 +4745,19 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -5291,9 +5371,9 @@ } }, "node_modules/ox": { - "version": "0.14.33", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", - "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", + "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", "funding": [ { "type": "github", @@ -5301,14 +5381,14 @@ } ], "license": "MIT", + "optional": true, "dependencies": { - "@adraffy/ens-normalize": "^1.11.0", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "1.9.1", - "@noble/hashes": "^1.8.0", - "@scure/bip32": "^1.7.0", - "@scure/bip39": "^1.6.0", - "abitype": "^1.2.3", + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { @@ -5320,6 +5400,19 @@ } } }, + "node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -5399,13 +5492,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5532,6 +5624,16 @@ "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/qrcode": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", @@ -6017,6 +6119,37 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyrainbow": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", @@ -6049,9 +6182,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", - "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", + "version": "4.23.7", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.7.tgz", + "integrity": "sha512-3f/u/+UDCNQ7iwUZW9FCMnNGIHzElGJYh0S/yy8IvWSsn5O7fEO/897FaG7FA2W8yryiRyuwXZ1PYLAKYaqSuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6279,9 +6412,9 @@ } }, "node_modules/viem": { - "version": "2.55.10", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.10.tgz", - "integrity": "sha512-Q9Ba+/ma81U2M5o5P2AQ7Ux8rTIwmCZvUcr8rKdQ22bV0IBFHllM2m5gWDP8hFaUN2nH2oW3QG44amRazflYNQ==", + "version": "2.55.11", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.11.tgz", + "integrity": "sha512-RR5MwtdUnFfqw6ZGoFptizywyLOkLuhTL7UafoP3Irf2upXpANakQkLgGqY4H7A7+8JBxjUs6lElGF5zuGjMEw==", "funding": [ { "type": "github", @@ -6308,6 +6441,84 @@ } } }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/ox": { + "version": "0.14.33", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", + "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/viem/node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -6407,6 +6618,19 @@ } } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", @@ -6497,6 +6721,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/src/agent/server.ts b/src/agent/server.ts index effd188..ec06b4a 100644 --- a/src/agent/server.ts +++ b/src/agent/server.ts @@ -1,4 +1,4 @@ -import { createServer, type IncomingMessage } from "node:http"; +import { createServer } from "node:http"; import { lstat, mkdir, readFile, unlink } from "node:fs/promises"; import { dirname } from "node:path"; import type { Duplex } from "node:stream"; diff --git a/tsconfig.server.json b/tsconfig.server.json index 0106b3c..89f6cbd 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -6,6 +6,8 @@ "outDir": "dist/server", "rootDir": "src", "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "esModuleInterop": true, From 9b8ad9b478e654689ab4a44a95e2fcdad2161bbb Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:31:03 +0300 Subject: [PATCH 02/10] feat: implement browser-proxy wallet signing relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable wallet transaction and message signing from inside workspace VMs without private keys ever entering the guest. The relay chain is: guest process → bloom-workspace CLI → guest-control Unix socket → agent HTTP (signing.request/signing.status/signing.resolve) → control plane HTTP (/api/workspaces/:id/signing/pending|resolve) → browser 3s poll → wallet provider (personal_sign, eth_sendTransaction, eth_signTypedData_v4) Changes across 13 files: - Python guest controller: signing.request/status/resolve operations, in-memory pending queue with TTL expiry, capability flags now true - Guest CLI (bloom-workspace): 'sign' subcommand with polling + timeout - TypeScript protocol: SigningRequest/SigningResolve envelope types in guest-protocol.ts, z.boolean() for walletSigning/transactions in results - Agent server: 3 new HTTP endpoints, randomUUID import - Agent client: signingPending/signingResolve/signingRequest methods - Control plane: WorkspaceService.signingPending/signingResolve, 2 new authenticated HTTP routes - Browser: 3s signing poll, resolveSigningRequest dispatches to wallet, starts on workspace running, cleaned up on close - SIWE statement updated to disclose signing relay - WalletConnect methods expanded to include transaction methods - Bootstrap comment updated to document relay-based signing --- ops/bloom/guest-bootstrap.sh | 6 +- ops/guest-control/README.md | 6 +- ops/guest-control/bloom-guest-control.py | 103 +++++++++++++++++++++-- ops/guest-control/bloom-workspace | 27 ++++++ src/agent/server.ts | 24 ++++++ src/control/agent-client.ts | 3 + src/control/auth.ts | 2 +- src/control/jobs-guest.test.ts | 2 +- src/control/server.ts | 18 ++++ src/control/workspaces.ts | 15 ++++ src/guest-protocol.ts | 13 +++ src/guest/results.ts | 4 +- web/main.ts | 46 +++++++++- 13 files changed, 253 insertions(+), 16 deletions(-) diff --git a/ops/bloom/guest-bootstrap.sh b/ops/bloom/guest-bootstrap.sh index ddd325c..a5418ab 100755 --- a/ops/bloom/guest-bootstrap.sh +++ b/ops/bloom/guest-bootstrap.sh @@ -2,8 +2,10 @@ set -eu # Provision Bloom inside a workspace using the authenticated login address as a -# watch-only wallet. This helper has no signer path by design: it accepts one -# EVM address, never a key/passphrase, and refuses to run beside signer state. +# watch-only wallet. This helper has no direct signer path: it accepts one EVM +# address, never a key/passphrase, and refuses to run beside signer state. +# Transaction signing is relayed to the user's browser wallet through the guest +# control protocol — private keys never enter the workspace VM. BLOOM_BIN=${BLOOM_BIN:-/usr/local/bin/bloom} BLOOM_WORKSPACE_ROOT=/workspace diff --git a/ops/guest-control/README.md b/ops/guest-control/README.md index d386465..54483d8 100644 --- a/ops/guest-control/README.md +++ b/ops/guest-control/README.md @@ -2,8 +2,10 @@ `bloom-guest-control.py` is the guest-owned implementation of protocol v1. It supports bounded file chunks, structured jobs, absolute-cursor log reads, -process-group cancellation, watch-only Bloom status, and one-time SSH/NFS -configuration. It has no TCP listener: the production transports are QEMU virtio-serial stdio, AF_VSOCK port +process-group cancellation, watch-only Bloom status, one-time SSH/NFS +configuration, and wallet signing relay (relays `personal_sign`, +`eth_sendTransaction`, and `eth_signTypedData_v4` requests to the user's +browser wallet via the agent↔control↔browser polling chain). It has no TCP listener: the production transports are QEMU virtio-serial stdio, AF_VSOCK port 5001, and a mode-0600 guest-local Unix socket for `bloom-workspace`. Stdio and socket transports may run concurrently and share one bounded job table. diff --git a/ops/guest-control/bloom-guest-control.py b/ops/guest-control/bloom-guest-control.py index c58d457..cce81c7 100755 --- a/ops/guest-control/bloom-guest-control.py +++ b/ops/guest-control/bloom-guest-control.py @@ -47,6 +47,12 @@ MAX_JOB_FILE_BYTES = 64 * 1024 * 1024 MAX_JOB_TIMEOUT_MS = 2 * 60 * 60 * 1000 JOB_KILL_GRACE_SECONDS = 1.0 +MAX_SIGNING_METHODS = frozenset({"eth_sendTransaction", "personal_sign", "eth_signTypedData_v4"}) +MAX_SIGNING_PARAMS_BYTES = 32 * 1024 +MAX_SIGNING_RESULT_BYTES = 32 * 1024 +MAX_PENDING_SIGNING = 8 +SIGNING_TIMEOUT_SECONDS = 120.0 +SIGNING_RETENTION_SECONDS = 30.0 REQUEST_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") @@ -111,9 +117,17 @@ def validate_request(raw: Any) -> dict[str, Any]: "job.cancel": base | {"jobId"}, "bloom.status": base, "connections.configure": base | {"workspaceId", "wallet", "caPublicKey", "nfs"}, + "signing.request": base | {"method", "params"}, + "signing.status": base | {"requestId"}, + "signing.pending": base, } - require(operation in fields, "invalid_request", "unknown guest operation") - require_exact_keys(raw, fields[operation]) + # signing.resolve has optional result/error — validated in handler + if operation == "signing.resolve": + required_keys = base | {"requestId"} + require(set(raw) == required_keys or set(raw) == required_keys | {"result"} or set(raw) == required_keys | {"error"}, "invalid_request", "request fields do not match the operation contract") + else: + require(operation in fields, "invalid_request", "unknown guest operation") + require_exact_keys(raw, fields[operation]) return raw @@ -719,6 +733,8 @@ def __init__(self, files: WorkspaceFiles, jobs: JobEngine): self.mountd: subprocess.Popen[bytes] | None = None self.connection_scope: tuple[str, str, bool] | None = None self.connection_lock = threading.Lock() + self.signing_lock = threading.Lock() + self.pending_signing: OrderedDict[str, dict[str, Any]] = OrderedDict() def handle(self, raw: Any) -> dict[str, Any]: request_id = raw.get("id") if isinstance(raw, dict) and isinstance(raw.get("id"), str) and REQUEST_ID.fullmatch(raw["id"]) else "invalid" @@ -728,7 +744,7 @@ def handle(self, raw: Any) -> dict[str, Any]: if operation == "hello": result = { "protocolVersion": PROTOCOL_VERSION, - "operations": ["fs.list", "fs.read", "fs.write", "fs.delete", "job.start", "job.status", "job.cancel", "bloom.status", "connections.configure"], + "operations": ["fs.list", "fs.read", "fs.write", "fs.delete", "job.start", "job.status", "job.cancel", "bloom.status", "connections.configure", "signing.request", "signing.status", "signing.pending", "signing.resolve"], "limits": { "fileChunkBytes": MAX_FILE_CHUNK_BYTES, "fileBytes": MAX_FILE_BYTES, @@ -757,6 +773,14 @@ def handle(self, raw: Any) -> dict[str, Any]: result = self._bloom_status() elif operation == "connections.configure": result = self._configure_connections(request) + elif operation == "signing.request": + result = self._signing_request(request) + elif operation == "signing.status": + result = self._signing_status(request) + elif operation == "signing.pending": + result = self._signing_pending() + elif operation == "signing.resolve": + result = self._signing_resolve(request) else: raise ControlError("invalid_request", "unknown guest operation") return {"version": PROTOCOL_VERSION, "id": request_id, "ok": True, "result": result} @@ -784,12 +808,81 @@ def _bloom_status(self) -> dict[str, Any]: "files": True, "jobs": True, "bloomRead": executable and watch_identity, - "walletSigning": False, - "transactions": False, + "walletSigning": True, + "transactions": True, }, "helper": {"name": "bloom-workspace", "protocolVersion": PROTOCOL_VERSION}, } + def _cleanup_signing_locked(self) -> None: + now = time.monotonic() + expired: list[str] = [] + for rid, entry in self.pending_signing.items(): + age = now - entry["created_at"] + if age > SIGNING_TIMEOUT_SECONDS + SIGNING_RETENTION_SECONDS: + expired.append(rid) + elif age > SIGNING_TIMEOUT_SECONDS and entry["resolved"] is None: + entry["resolved"] = {"status": "rejected", "error": "signing request timed out"} + entry["resolved_at"] = now + for rid in expired: + del self.pending_signing[rid] + + def _signing_request(self, request: dict[str, Any]) -> dict[str, Any]: + method = request["method"] + params = request["params"] + require(isinstance(method, str) and method in MAX_SIGNING_METHODS, "invalid_request", "unsupported signing method") + require(isinstance(params, list) and len(params) <= 8, "invalid_request", "invalid signing params") + params_json = json.dumps(params, separators=(",", ":")) + require(len(params_json.encode("utf-8")) <= MAX_SIGNING_PARAMS_BYTES, "limit_exceeded", "signing params are too large") + request_id = f"sig_{uuid.uuid4().hex[:16]}" + with self.signing_lock: + self._cleanup_signing_locked() + require(len(self.pending_signing) < MAX_PENDING_SIGNING, "limit_exceeded", "too many pending signing requests") + self.pending_signing[request_id] = {"method": method, "params": params, "created_at": time.monotonic(), "resolved": None} + return {"requestId": request_id} + + def _signing_status(self, request: dict[str, Any]) -> dict[str, Any]: + request_id = request["requestId"] + require(isinstance(request_id, str) and len(request_id) <= 64, "invalid_request", "invalid request id") + with self.signing_lock: + self._cleanup_signing_locked() + entry = self.pending_signing.get(request_id) + if entry is None: + raise ControlError("not_found", "signing request not found") + if entry["resolved"] is None: + return {"status": "pending"} + return entry["resolved"] + + def _signing_pending(self) -> dict[str, Any]: + with self.signing_lock: + self._cleanup_signing_locked() + requests = [ + {"requestId": rid, "method": entry["method"], "params": entry["params"]} + for rid, entry in self.pending_signing.items() + if entry["resolved"] is None + ] + return {"requests": requests} + + def _signing_resolve(self, request: dict[str, Any]) -> dict[str, Any]: + request_id = request["requestId"] + result = request.get("result") + error = request.get("error") + require(isinstance(request_id, str) and len(request_id) <= 64, "invalid_request", "invalid request id") + require(result is not None or error is not None, "invalid_request", "either result or error is required") + if result is not None: + result_json = json.dumps(result, separators=(",", ":")) + require(len(result_json.encode("utf-8")) <= MAX_SIGNING_RESULT_BYTES, "limit_exceeded", "signing result is too large") + with self.signing_lock: + entry = self.pending_signing.get(request_id) + if entry is None or entry["resolved"] is not None: + raise ControlError("not_found", "signing request not found or already resolved") + if error is not None: + entry["resolved"] = {"status": "rejected", "error": str(error)[:1024]} + else: + entry["resolved"] = {"status": "resolved", "result": result} + entry["resolved_at"] = time.monotonic() + return {} + def _configure_connections(self, request: dict[str, Any]) -> dict[str, Any]: with self.connection_lock: return self._configure_connections_locked(request) diff --git a/ops/guest-control/bloom-workspace b/ops/guest-control/bloom-workspace index 5eaa44f..f2e977c 100755 --- a/ops/guest-control/bloom-workspace +++ b/ops/guest-control/bloom-workspace @@ -87,6 +87,11 @@ def main() -> int: jobs_status.add_argument("--cursor", type=int, default=0) jobs_cancel = jobs.add_parser("cancel") jobs_cancel.add_argument("job_id") + + sign = commands.add_parser("sign", help="request a wallet signature through the browser relay") + sign.add_argument("method", choices=["personal_sign", "eth_sendTransaction", "eth_signTypedData_v4"]) + sign.add_argument("params", nargs="?", help="JSON-encoded params array (default: read from stdin)") + sign.add_argument("--timeout", type=int, default=120, help="timeout in seconds (default: 120)") args = parser.parse_args() if args.command == "status": @@ -149,6 +154,28 @@ def main() -> int: write_json(request("job.status", jobId=args.job_id, logOffset=args.cursor, maxBytes=MAX_CHUNK_BYTES)) elif args.jobs_command == "cancel": write_json(request("job.cancel", jobId=args.job_id)) + elif args.command == "sign": + import time as _time + if args.params is not None: + params_raw = args.params + else: + params_raw = sys.stdin.read().strip() + try: + params = json.loads(params_raw) + except json.JSONDecodeError as error: + raise SystemExit(f"invalid JSON params: {error}") from None + if not isinstance(params, list): + raise SystemExit("params must be a JSON array") + result = request("signing.request", method=args.method, params=params) + request_id = result["requestId"] + deadline = _time.monotonic() + args.timeout + while _time.monotonic() < deadline: + status = request("signing.status", requestId=request_id) + if status["status"] != "pending": + write_json(status) + return 0 if status["status"] == "resolved" else 1 + _time.sleep(2) + raise SystemExit("signing request timed out") return 0 diff --git a/src/agent/server.ts b/src/agent/server.ts index ec06b4a..4c40715 100644 --- a/src/agent/server.ts +++ b/src/agent/server.ts @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; import { lstat, mkdir, readFile, unlink } from "node:fs/promises"; import { dirname } from "node:path"; import type { Duplex } from "node:stream"; @@ -201,6 +202,29 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { }); } catch (error) { next(error); } }); + app.get("/v1/workspaces/:id/signing/pending", async (request, response, next) => { + try { + const call = requireGuest(runtime, request.params.id ?? ""); + const result = await call({ version: 1, id: `signing_pending_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "signing.pending" }, 10_000); + response.json(result); + } catch (error) { next(error); } + }); + app.post("/v1/workspaces/:id/signing/request", async (request, response, next) => { + try { + const call = requireGuest(runtime, request.params.id ?? ""); + const body = z.object({ method: z.enum(["eth_sendTransaction", "personal_sign", "eth_signTypedData_v4"]), params: z.array(z.unknown()).min(1).max(8) }).strict().parse(request.body); + const result = await call({ version: 1, id: `signing_req_${randomUUID().replaceAll("-", "").slice(0, 16)}`, operation: "signing.request", method: body.method, params: body.params }, 10_000); + response.status(201).json(result); + } catch (error) { next(error); } + }); + app.post("/v1/workspaces/:id/signing/resolve", async (request, response, next) => { + try { + const call = requireGuest(runtime, request.params.id ?? ""); + const body = z.object({ requestId: z.string().min(1).max(64), result: z.unknown().optional(), error: z.string().min(1).max(1024).optional() }).refine((v) => v.result !== undefined || v.error !== undefined, "either result or error is required").parse(request.body); + const result = await call({ version: 1, id: `signing_res_${randomUUID().replaceAll("-", "").slice(0, 16)}`, operation: "signing.resolve", requestId: body.requestId, ...(body.result !== undefined ? { result: body.result } : {}), ...(body.error !== undefined ? { error: body.error } : {}) }, 10_000); + response.json(result); + } catch (error) { next(error); } + }); app.post("/v1/workspaces/:id/connections/ssh", async (request, response, next) => { try { if (!ssh || !runtime.sshEndpoint) throw new RuntimeDataError("SSH is disabled by the operator", 501); diff --git a/src/control/agent-client.ts b/src/control/agent-client.ts index 804e196..0321d98 100644 --- a/src/control/agent-client.ts +++ b/src/control/agent-client.ts @@ -30,6 +30,9 @@ export class AgentClient { } cancelJob(id: string, jobId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/jobs/${encodeURIComponent(jobId)}`); } bloomStatus(id: string) { return this.request("GET", `/v1/workspaces/${encodeURIComponent(id)}/bloom`); } + signingPending(id: string) { return this.request<{ requests: { requestId: string; method: string; params: unknown[] }[] }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/signing/pending`); } + signingRequest(id: string, method: string, params: unknown[]) { return this.request<{ requestId: string }>("POST", `/v1/workspaces/${encodeURIComponent(id)}/signing/request`, { method, params }); } + signingResolve(id: string, requestId: string, resolution: { result?: unknown; error?: string }) { return this.request>("POST", `/v1/workspaces/${encodeURIComponent(id)}/signing/resolve`, { requestId, ...resolution }); } connections(id: string) { return this.request<{ connections: Record<"ssh" | "nfs", { status: "available" | "disabled" | "unsupported"; reason: string; instructions: string[] }> }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/connections`); } issueSsh(id: string, body: SshLeaseBody) { return this.request("POST", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh`, body); } revokeSsh(id: string, leaseId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh/${encodeURIComponent(leaseId)}`); } diff --git a/src/control/auth.ts b/src/control/auth.ts index fb6036d..c50da8a 100644 --- a/src/control/auth.ts +++ b/src/control/auth.ts @@ -22,7 +22,7 @@ export function issueChallenge(db: BloomDatabase, config: Config, ipHash: string domain: url.host, uri: url.origin, chainId: config.authChainId, - statement: "Sign in to request a disposable Bloom workspace. This does not authorize transactions.", + statement: "Sign in to request a Bloom workspace. Workspace processes may request wallet signatures; you will approve each request in your wallet.", issuedAt: issuedAt.toISOString(), expirationTime: expirationTime.toISOString(), }; diff --git a/src/control/jobs-guest.test.ts b/src/control/jobs-guest.test.ts index a75c4f4..fc66363 100644 --- a/src/control/jobs-guest.test.ts +++ b/src/control/jobs-guest.test.ts @@ -56,7 +56,7 @@ describe("guest job, file, and Bloom control service", () => { result: { identity: { kind: "watch", address: "0x1111111111111111111111111111111111111111" }, mount: { path: "/bloom" }, - capabilities: { files: true, jobs: true, walletSigning: false, transactions: false }, + capabilities: { files: true, jobs: true, walletSigning: true, transactions: true }, }, }); diff --git a/src/control/server.ts b/src/control/server.ts index d5b392c..4d04fec 100644 --- a/src/control/server.ts +++ b/src/control/server.ts @@ -238,6 +238,24 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { response.json(await workspaces.bloomStatus(session.wallet, String(request.params.id ?? ""))); } catch (error) { next(error); } }); + app.get("/api/workspaces/:id/signing/pending", requireSession, async (request, response, next) => { + try { + const { session } = response.locals.context as { session: Session }; + response.json(await workspaces.signingPending(session.wallet, String(request.params.id ?? ""))); + } catch (error) { next(error); } + }); + app.post("/api/workspaces/:id/signing/resolve", requireSession, async (request, response, next) => { + try { + const { session } = response.locals.context as { session: Session }; + const body = z.object({ + requestId: z.string().min(1).max(64), + result: z.unknown().optional(), + error: z.string().min(1).max(1024).optional(), + }).refine((v) => v.result !== undefined || v.error !== undefined, "either result or error is required").parse(request.body); + await workspaces.signingResolve(session.wallet, String(request.params.id ?? ""), body.requestId, { ...(body.result !== undefined ? { result: body.result } : {}), ...(body.error !== undefined ? { error: body.error } : {}) }); + response.status(204).end(); + } catch (error) { next(error); } + }); app.get("/api/workspaces/:id/connections", requireSession, async (request, response, next) => { try { const { session } = response.locals.context as { session: Session }; diff --git a/src/control/workspaces.ts b/src/control/workspaces.ts index 4162ace..0bb7b5c 100644 --- a/src/control/workspaces.ts +++ b/src/control/workspaces.ts @@ -151,6 +151,21 @@ export class WorkspaceService { catch (error) { throw this.agentWorkspaceError(error); } } + async signingPending(wallet: string, id: string) { + const row = this.ownedRunning(wallet, id, "signing_pending"); + try { return await this.agent.signingPending(row.id); } + catch (error) { throw this.agentWorkspaceError(error); } + } + + async signingResolve(wallet: string, id: string, requestId: string, resolution: { result?: unknown; error?: string }) { + const row = this.ownedRunning(wallet, id, "signing_resolve"); + try { + const result = await this.agent.signingResolve(row.id, requestId, resolution); + audit(this.db, "workspace.signing_resolved", row.wallet, row.id, { requestId, approved: resolution.error === undefined }); + return result; + } catch (error) { throw this.agentWorkspaceError(error); } + } + async connections(wallet: string, id: string) { const row = this.ownedRunning(wallet, id, "connections"); try { return await this.agent.connections(row.id); } diff --git a/src/guest-protocol.ts b/src/guest-protocol.ts index f8aba33..c82f8be 100644 --- a/src/guest-protocol.ts +++ b/src/guest-protocol.ts @@ -59,6 +59,19 @@ export const GuestRequest = z.discriminatedUnion("operation", [ caPublicKey: z.string().min(32).max(1024).regex(/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/), nfs: z.boolean(), }), + Envelope.extend({ + operation: z.literal("signing.request"), + method: z.enum(["eth_sendTransaction", "personal_sign", "eth_signTypedData_v4"]), + params: z.array(z.unknown()).min(1).max(8), + }), + Envelope.extend({ operation: z.literal("signing.status"), requestId: z.string().min(1).max(64) }), + Envelope.extend({ operation: z.literal("signing.pending") }), + Envelope.extend({ + operation: z.literal("signing.resolve"), + requestId: z.string().min(1).max(64), + result: z.unknown().optional(), + error: z.string().min(1).max(1024).optional(), + }).refine((v) => v.result !== undefined || v.error !== undefined, "either result or error is required"), ]); export type GuestRequest = z.infer; diff --git a/src/guest/results.ts b/src/guest/results.ts index 7afab7e..04b8eda 100644 --- a/src/guest/results.ts +++ b/src/guest/results.ts @@ -10,8 +10,8 @@ export const BloomGuestStatus = z.object({ files: z.boolean(), jobs: z.boolean(), bloomRead: z.boolean(), - walletSigning: z.literal(false), - transactions: z.literal(false), + walletSigning: z.boolean(), + transactions: z.boolean(), }), helper: z.object({ name: z.literal("bloom-workspace"), protocolVersion: z.literal(1) }), }).superRefine((status, context) => { diff --git a/web/main.ts b/web/main.ts index 75e9f3d..f54ba1f 100644 --- a/web/main.ts +++ b/web/main.ts @@ -105,6 +105,7 @@ let fit: FitAddon | undefined; let workspacePollTimer: number | undefined; let leaseTimer: number | undefined; let jobPollTimer: number | undefined; +let signingPollTimer: number | undefined; let jobEvents: EventSource | undefined; let activeJob: JobStatus | undefined; let activeConnection: ConnectionGrant | undefined; @@ -238,7 +239,7 @@ async function authenticate(provider: WalletProvider, address: string, value: Au expirationTime: new Date(value.expirationTime), version: "1", }); - setMessage("Approve the login message in your wallet. This does not authorize a transaction."); + setMessage("Approve the login message in your wallet."); const signature = await wallet.signMessage({ account: normalizedAddress, message }); const verified = await api<{ wallet: string; csrfToken: string }>("/api/auth/verify", { method: "POST", body: JSON.stringify({ message, signature }) }); session = { ...session, authenticated: true, wallet: verified.wallet, csrfToken: verified.csrfToken }; @@ -271,13 +272,13 @@ async function connectMobileWallet() { const provider = await EthereumProvider.init({ projectId: walletConnectProjectId, optionalChains: [value.chainId], - methods: ["personal_sign"], + methods: ["personal_sign", "eth_sendTransaction", "eth_signTypedData_v4"], events: ["accountsChanged", "chainChanged"], showQrModal: true, telemetryEnabled: false, metadata: { name: "Bloom Workspaces", - description: "Sign in to an isolated Linux workspace. Login signatures only; no transactions.", + description: "Sign in to an isolated Linux workspace. Sign messages and transactions are relayed for your approval.", url: location.origin, icons: [`${location.origin}/bloom-workspaces.svg`], redirect: { universal: `${location.origin}/` }, @@ -469,6 +470,7 @@ function renderWorkspace(workspace?: Workspace) { consoleElement.classList.remove("hidden"); if (loadedWorkspaceId !== workspace.id) resetWorkspaceTools(workspace.id); if (isAvailable("terminal") && !socket) openTerminal(workspace); + if (!signingPollTimer) startSigningPoll(workspace.id); } updateLease(workspace); workspacePollTimer = window.setTimeout(refreshWorkspace, workspace.state === "running" ? 10_000 : 1_500); @@ -513,6 +515,7 @@ function resetWorkspaceTools(id: string) { function closeWorkspaceResources() { closeTerminal(); closeJobStream(); + closeSigningPoll(); clearConnectionGrant(); loadedWorkspaceId = undefined; activeJob = undefined; @@ -520,6 +523,43 @@ function closeWorkspaceResources() { window.clearTimeout(leaseTimer); } +function closeSigningPoll() { + window.clearInterval(signingPollTimer); + signingPollTimer = undefined; +} + +function startSigningPoll(workspaceId: string) { + closeSigningPoll(); + const seen = new Set(); + signingPollTimer = window.setInterval(async () => { + if (!walletBinding || !session.authenticated) return; + try { + const result = await api<{ requests: { requestId: string; method: string; params: unknown[] }[] }>(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/pending`); + for (const request of result.requests) { + if (seen.has(request.requestId)) continue; + seen.add(request.requestId); + void resolveSigningRequest(workspaceId, request); + } + } catch { /* workspace may have stopped */ } + }, 3_000); +} + +async function resolveSigningRequest(workspaceId: string, request: { requestId: string; method: string; params: unknown[] }) { + if (!walletBinding) return; + try { + const result = await (walletBinding.provider as { request(args: { method: string; params: unknown[] }): Promise }).request({ method: request.method, params: request.params }); + await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/resolve`, { + method: "POST", + body: JSON.stringify({ requestId: request.requestId, result }), + }); + } catch (error) { + await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/resolve`, { + method: "POST", + body: JSON.stringify({ requestId: request.requestId, error: error instanceof Error ? error.message : "Wallet request failed" }), + }).catch(() => undefined); + } +} + function openTerminal(workspace: Workspace) { terminal = new Terminal({ cursorBlink: true, From baf12882dbb22ad6f4cd89988a535f1ef0e12580 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:19:29 +0300 Subject: [PATCH 03/10] feat: add signing approval dialog, integration tests, and hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Browser now shows a preview dialog before forwarding signing requests to the wallet. Users see method, from, to, value, and data before approving. This prevents blind signing of malicious transactions. - formatSigningRequest() renders human-readable summaries for personal_sign, eth_sendTransaction, and eth_signTypedData_v4. - seen-set pruned to prevent unbounded memory growth during long sessions. - 5 integration tests covering: full request→pending→resolve lifecycle, method/param validation, double-resolve prevention, error rejection, and unknown request ID handling. - Signing dialog HTML + CSS. --- src/control/jobs-guest.test.ts | 80 ++++++++++++++++++++++++++++++++++ web/index.html | 10 +++++ web/main.ts | 46 ++++++++++++++++++- web/style.css | 1 + 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/control/jobs-guest.test.ts b/src/control/jobs-guest.test.ts index fc66363..57c0cda 100644 --- a/src/control/jobs-guest.test.ts +++ b/src/control/jobs-guest.test.ts @@ -186,6 +186,86 @@ describe("guest job, file, and Bloom control service", () => { expect(cancelled.status, cancelled.stderr).toBe(0); expect(await harness.waitForTerminal(jobId)).toMatchObject({ state: "cancelled" }); }); + + it("relays signing requests through the full request-pending-resolve lifecycle", async () => { + const harness = await GuestHarness.create(); + + // Request a personal_sign + const req = await harness.request({ operation: "signing.request", method: "personal_sign", params: ["0x48656c6c6f", "0x1111111111111111111111111111111111111111"] }); + expect(req.ok).toBe(true); + const requestId = (req.result as { requestId: string }).requestId; + expect(requestId).toMatch(/^sig_/); + + // Status should show pending + const status = await harness.request({ operation: "signing.status", requestId }); + expect(status).toMatchObject({ ok: true, result: { status: "pending" } }); + + // Pending list should contain it + const pending = await harness.request({ operation: "signing.pending" }); + expect(pending).toMatchObject({ ok: true }); + const pendingReqs = (pending.result as { requests: { requestId: string; method: string }[] }).requests; + expect(pendingReqs).toEqual(expect.arrayContaining([expect.objectContaining({ requestId, method: "personal_sign" })])); + + // Resolve with a result + const resolved = await harness.request({ operation: "signing.resolve", requestId, result: "0xdeadbeef" }); + expect(resolved).toMatchObject({ ok: true, result: {} }); + + // Status should now show resolved + const finalStatus = await harness.request({ operation: "signing.status", requestId }); + expect(finalStatus).toMatchObject({ ok: true, result: { status: "resolved", result: "0xdeadbeef" } }); + + // Pending should no longer contain it + const pendingAfter = await harness.request({ operation: "signing.pending" }); + const pendingReqsAfter = (pendingAfter.result as { requests: { requestId: string }[] }).requests; + expect(pendingReqsAfter.find((r) => r.requestId === requestId)).toBeUndefined(); + }); + + it("rejects unsupported signing methods and enforces param limits", async () => { + const harness = await GuestHarness.create(); + + expect(await harness.request({ operation: "signing.request", method: "eth_sign", params: ["0x1"] })) + .toMatchObject({ ok: false, error: { code: "invalid_request" } }); + + expect(await harness.request({ operation: "signing.request", method: "personal_sign", params: "not-an-array" })) + .toMatchObject({ ok: false, error: { code: "invalid_request" } }); + + const oversizedParams = Array.from({ length: 10 }, (_, i) => `0x${i}`); + expect(await harness.request({ operation: "signing.request", method: "personal_sign", params: oversizedParams })) + .toMatchObject({ ok: false, error: { code: "invalid_request" } }); + }); + + it("prevents resolving a signing request twice", async () => { + const harness = await GuestHarness.create(); + + const req = await harness.request({ operation: "signing.request", method: "personal_sign", params: ["0x48656c6c6f", "0x1111111111111111111111111111111111111111"] }); + const requestId = (req.result as { requestId: string }).requestId; + + const first = await harness.request({ operation: "signing.resolve", requestId, result: "0xabc" }); + expect(first.ok).toBe(true); + + const second = await harness.request({ operation: "signing.resolve", requestId, result: "0xdef" }); + expect(second).toMatchObject({ ok: false, error: { code: "not_found" } }); + }); + + it("allows rejecting a signing request with an error", async () => { + const harness = await GuestHarness.create(); + + const req = await harness.request({ operation: "signing.request", method: "personal_sign", params: ["0x48656c6c6f", "0x1111111111111111111111111111111111111111"] }); + const requestId = (req.result as { requestId: string }).requestId; + + const rejected = await harness.request({ operation: "signing.resolve", requestId, error: "user rejected" }); + expect(rejected).toMatchObject({ ok: true, result: {} }); + + const status = await harness.request({ operation: "signing.status", requestId }); + expect(status).toMatchObject({ ok: true, result: { status: "rejected", error: "user rejected" } }); + }); + + it("returns not_found for unknown signing request IDs", async () => { + const harness = await GuestHarness.create(); + + expect(await harness.request({ operation: "signing.status", requestId: "sig_nonexistent" })) + .toMatchObject({ ok: false, error: { code: "not_found" } }); + }); }); type RequestFields = { operation: string } & Record; diff --git a/web/index.html b/web/index.html index 4c2003f..f95b6ea 100644 --- a/web/index.html +++ b/web/index.html @@ -174,6 +174,16 @@

Workspace console

+ +
+

Signing request

+

Approve wallet request?

+

+

+        
+
+
+

Please confirm

diff --git a/web/main.ts b/web/main.ts index f54ba1f..966b528 100644 --- a/web/main.ts +++ b/web/main.ts @@ -528,6 +528,30 @@ function closeSigningPoll() { signingPollTimer = undefined; } +function formatSigningRequest(method: string, params: unknown[]): string { + if (method === "personal_sign") { + const message = String(params[0] ?? ""); + const address = String(params[1] ?? ""); + return `Method: personal_sign\nFrom: ${address}\nMessage:\n${message}`; + } + if (method === "eth_sendTransaction") { + const tx = (params[0] ?? {}) as Record; + const from = String(tx.from ?? "?"); + const to = String(tx.to ?? "?"); + const value = String(tx.value ?? "0x0"); + const data = String(tx.data ?? "0x"); + const lines = [`Method: eth_sendTransaction`, `From: ${from}`, `To: ${to}`, `Value: ${value} wei`]; + if (data !== "0x" && data.length > 10) lines.push(`Data: ${data.slice(0, 100)}${data.length > 100 ? "…" : ""}`); + return lines.join("\n"); + } + if (method === "eth_signTypedData_v4") { + const address = String(params[0] ?? ""); + const typed = params[1]; + return `Method: eth_signTypedData_v4\nFrom: ${address}\nTyped data:\n${typeof typed === "string" ? typed.slice(0, 500) : JSON.stringify(typed, null, 2).slice(0, 500)}`; + } + return `Method: ${method}\nParams: ${JSON.stringify(params).slice(0, 500)}`; +} + function startSigningPoll(workspaceId: string) { closeSigningPoll(); const seen = new Set(); @@ -538,14 +562,32 @@ function startSigningPoll(workspaceId: string) { for (const request of result.requests) { if (seen.has(request.requestId)) continue; seen.add(request.requestId); - void resolveSigningRequest(workspaceId, request); + void promptAndResolveSigningRequest(workspaceId, request); } + if (seen.size > 50) { const keep = new Set(result.requests.map((r) => r.requestId)); for (const id of seen) if (!keep.has(id)) seen.delete(id); } } catch { /* workspace may have stopped */ } }, 3_000); } -async function resolveSigningRequest(workspaceId: string, request: { requestId: string; method: string; params: unknown[] }) { +async function promptAndResolveSigningRequest(workspaceId: string, request: { requestId: string; method: string; params: unknown[] }) { if (!walletBinding) return; + const dialog = $("signing-dialog") as HTMLDialogElement; + const description = $("signing-description"); + const detail = $("signing-detail"); + description.textContent = `Your workspace is requesting a wallet signature. Review the details below, then approve or reject.`; + detail.textContent = formatSigningRequest(request.method, request.params); + dialog.showModal(); + const choice = await new Promise((resolve) => { + const onClose = () => { dialog.removeEventListener("close", onClose); resolve(dialog.returnValue || "reject"); }; + dialog.addEventListener("close", onClose); + }); + if (choice !== "approve") { + await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/resolve`, { + method: "POST", + body: JSON.stringify({ requestId: request.requestId, error: "User rejected the signing request" }), + }).catch(() => undefined); + return; + } try { const result = await (walletBinding.provider as { request(args: { method: string; params: unknown[] }): Promise }).request({ method: request.method, params: request.params }); await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/resolve`, { diff --git a/web/style.css b/web/style.css index e7f4c1e..fd01c22 100644 --- a/web/style.css +++ b/web/style.css @@ -171,6 +171,7 @@ dialog::backdrop { background: #000a; backdrop-filter: blur(3px); } .dialog-card { padding: 24px; } .dialog-card h2 { font-size: 24px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; } +.signing-detail { background: #070a08; border: 1px solid var(--line); border-radius: 8px; padding: 12px; font: 12px ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; max-height: 240px; overflow-y: auto; margin: 12px 0 0; color: var(--muted); } @media (max-width: 980px) { .workspace-panel { grid-template-columns: 1fr; } From 964d3052790b61bca20af9ed8e8b190b22443c6d Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:51:57 +0300 Subject: [PATCH 04/10] refactor: rewrite signing relay to use Bloom's native outbox system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: replaces raw EIP-1193 passthrough with Bloom outbox bridge The previous implementation forwarded raw personal_sign / eth_sendTransaction calls from the guest to the browser wallet, bypassing all of Bloom's safety infrastructure. This rewrite goes through Bloom's native pipeline: guest process writes intent to /bloom/wallets//chains//outbox/new.tx → bloom serve stages it, simulates it, generates plan.md → guest controller reads pending entries via IPC → agent HTTP → control plane → browser → browser shows Bloom's plan.md → user approves → confirm written back via IPC This reuses Bloom's: - Intent parser (shell/TOML/JSON → typed RawIntent) - TxEngine (stage → simulate → confirm → broadcast) - Policy engine (spending limits, allowed contracts) - MEV/sandwich detection - Nonce management and conflict resolution - Outbox audit trail (intent.json, plan.md, policy_check.json) Changes (13 files, +173/-310): - Guest controller: replaced signing.request/status/pending/resolve with outbox.pending/confirm/cancel using bloom IPC (JSON-RPC over Unix socket) - Guest CLI: removed 'sign' subcommand (use bloom CLI's native outbox) - Agent server: replaced signing endpoints with outbox endpoints - Control plane: replaced signing routes with outbox routes - Browser: shows Bloom's plan.md instead of raw hex - Protocol: outbox.confirm/cancel schemas use txId to avoid envelope id clash - Tests: removed signing tests (outbox requires running bloom serve daemon) - Updated SIWE statement, WalletConnect methods, bootstrap comment, README --- ops/bloom/guest-bootstrap.sh | 5 +- ops/guest-control/README.md | 7 +- ops/guest-control/bloom-guest-control.py | 176 +++++++++++------------ ops/guest-control/bloom-workspace | 26 ---- src/agent/server.ts | 19 ++- src/control/agent-client.ts | 6 +- src/control/jobs-guest.test.ts | 80 ----------- src/control/server.ts | 22 +-- src/control/workspaces.ts | 23 ++- src/guest-protocol.ts | 21 +-- web/index.html | 12 +- web/main.ts | 84 ++++------- web/style.css | 2 +- 13 files changed, 173 insertions(+), 310 deletions(-) diff --git a/ops/bloom/guest-bootstrap.sh b/ops/bloom/guest-bootstrap.sh index a5418ab..1be93a7 100755 --- a/ops/bloom/guest-bootstrap.sh +++ b/ops/bloom/guest-bootstrap.sh @@ -4,8 +4,9 @@ set -eu # Provision Bloom inside a workspace using the authenticated login address as a # watch-only wallet. This helper has no direct signer path: it accepts one EVM # address, never a key/passphrase, and refuses to run beside signer state. -# Transaction signing is relayed to the user's browser wallet through the guest -# control protocol — private keys never enter the workspace VM. +# Transaction signing uses Bloom's native outbox system: the guest controller +# reads pending outbox entries from bloom serve via IPC and relays plan.md to +# the user's browser for approval. Private keys never enter the workspace VM. BLOOM_BIN=${BLOOM_BIN:-/usr/local/bin/bloom} BLOOM_WORKSPACE_ROOT=/workspace diff --git a/ops/guest-control/README.md b/ops/guest-control/README.md index 54483d8..a6a47ac 100644 --- a/ops/guest-control/README.md +++ b/ops/guest-control/README.md @@ -3,9 +3,8 @@ `bloom-guest-control.py` is the guest-owned implementation of protocol v1. It supports bounded file chunks, structured jobs, absolute-cursor log reads, process-group cancellation, watch-only Bloom status, one-time SSH/NFS -configuration, and wallet signing relay (relays `personal_sign`, -`eth_sendTransaction`, and `eth_signTypedData_v4` requests to the user's -browser wallet via the agent↔control↔browser polling chain). It has no TCP listener: the production transports are QEMU virtio-serial stdio, AF_VSOCK port +configuration, and outbox relay (reads pending outbox entries from bloom serve +via IPC and relays plan.md to the user's browser for approval). It has no TCP listener: the production transports are QEMU virtio-serial stdio, AF_VSOCK port 5001, and a mode-0600 guest-local Unix socket for `bloom-workspace`. Stdio and socket transports may run concurrently and share one bounded job table. @@ -41,7 +40,7 @@ The controller copies only the operator's controlled proxy and CA path settings; it does not inherit wallet, control-plane, or host credentials. `bloom.status` reports only the public watch address, whether `/bloom` is -mounted, and explicit false values for wallet signing and transactions. It +mounted, and capability flags. It never reads or returns passphrases, private keys, cookies, or session material. `connections.configure` is accepted once per guest scope and only by the diff --git a/ops/guest-control/bloom-guest-control.py b/ops/guest-control/bloom-guest-control.py index cce81c7..8e9a13a 100755 --- a/ops/guest-control/bloom-guest-control.py +++ b/ops/guest-control/bloom-guest-control.py @@ -47,12 +47,10 @@ MAX_JOB_FILE_BYTES = 64 * 1024 * 1024 MAX_JOB_TIMEOUT_MS = 2 * 60 * 60 * 1000 JOB_KILL_GRACE_SECONDS = 1.0 -MAX_SIGNING_METHODS = frozenset({"eth_sendTransaction", "personal_sign", "eth_signTypedData_v4"}) -MAX_SIGNING_PARAMS_BYTES = 32 * 1024 -MAX_SIGNING_RESULT_BYTES = 32 * 1024 -MAX_PENDING_SIGNING = 8 -SIGNING_TIMEOUT_SECONDS = 120.0 -SIGNING_RETENTION_SECONDS = 30.0 +BLOOM_SOCKET_PATH = os.environ.get("BLOOM_IPC_SOCKET", "/workspace/.bloom/run/bloom.sock") +MAX_OUTBOX_PLAN_BYTES = 64 * 1024 +MAX_OUTBOX_CHAINS = 16 +MAX_OUTBOX_PENDING = 32 REQUEST_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") @@ -117,17 +115,12 @@ def validate_request(raw: Any) -> dict[str, Any]: "job.cancel": base | {"jobId"}, "bloom.status": base, "connections.configure": base | {"workspaceId", "wallet", "caPublicKey", "nfs"}, - "signing.request": base | {"method", "params"}, - "signing.status": base | {"requestId"}, - "signing.pending": base, + "outbox.pending": base, + "outbox.confirm": base | {"txId", "chain", "wallet", "confirmText"}, + "outbox.cancel": base | {"txId", "chain", "wallet"}, } - # signing.resolve has optional result/error — validated in handler - if operation == "signing.resolve": - required_keys = base | {"requestId"} - require(set(raw) == required_keys or set(raw) == required_keys | {"result"} or set(raw) == required_keys | {"error"}, "invalid_request", "request fields do not match the operation contract") - else: - require(operation in fields, "invalid_request", "unknown guest operation") - require_exact_keys(raw, fields[operation]) + require(operation in fields, "invalid_request", "unknown guest operation") + require_exact_keys(raw, fields[operation]) return raw @@ -733,8 +726,6 @@ def __init__(self, files: WorkspaceFiles, jobs: JobEngine): self.mountd: subprocess.Popen[bytes] | None = None self.connection_scope: tuple[str, str, bool] | None = None self.connection_lock = threading.Lock() - self.signing_lock = threading.Lock() - self.pending_signing: OrderedDict[str, dict[str, Any]] = OrderedDict() def handle(self, raw: Any) -> dict[str, Any]: request_id = raw.get("id") if isinstance(raw, dict) and isinstance(raw.get("id"), str) and REQUEST_ID.fullmatch(raw["id"]) else "invalid" @@ -744,7 +735,7 @@ def handle(self, raw: Any) -> dict[str, Any]: if operation == "hello": result = { "protocolVersion": PROTOCOL_VERSION, - "operations": ["fs.list", "fs.read", "fs.write", "fs.delete", "job.start", "job.status", "job.cancel", "bloom.status", "connections.configure", "signing.request", "signing.status", "signing.pending", "signing.resolve"], + "operations": ["fs.list", "fs.read", "fs.write", "fs.delete", "job.start", "job.status", "job.cancel", "bloom.status", "connections.configure", "outbox.pending", "outbox.confirm", "outbox.cancel"], "limits": { "fileChunkBytes": MAX_FILE_CHUNK_BYTES, "fileBytes": MAX_FILE_BYTES, @@ -773,14 +764,12 @@ def handle(self, raw: Any) -> dict[str, Any]: result = self._bloom_status() elif operation == "connections.configure": result = self._configure_connections(request) - elif operation == "signing.request": - result = self._signing_request(request) - elif operation == "signing.status": - result = self._signing_status(request) - elif operation == "signing.pending": - result = self._signing_pending() - elif operation == "signing.resolve": - result = self._signing_resolve(request) + elif operation == "outbox.pending": + result = self._outbox_pending() + elif operation == "outbox.confirm": + result = self._outbox_confirm(request) + elif operation == "outbox.cancel": + result = self._outbox_cancel(request) else: raise ControlError("invalid_request", "unknown guest operation") return {"version": PROTOCOL_VERSION, "id": request_id, "ok": True, "result": result} @@ -814,73 +803,72 @@ def _bloom_status(self) -> dict[str, Any]: "helper": {"name": "bloom-workspace", "protocolVersion": PROTOCOL_VERSION}, } - def _cleanup_signing_locked(self) -> None: - now = time.monotonic() - expired: list[str] = [] - for rid, entry in self.pending_signing.items(): - age = now - entry["created_at"] - if age > SIGNING_TIMEOUT_SECONDS + SIGNING_RETENTION_SECONDS: - expired.append(rid) - elif age > SIGNING_TIMEOUT_SECONDS and entry["resolved"] is None: - entry["resolved"] = {"status": "rejected", "error": "signing request timed out"} - entry["resolved_at"] = now - for rid in expired: - del self.pending_signing[rid] - - def _signing_request(self, request: dict[str, Any]) -> dict[str, Any]: - method = request["method"] - params = request["params"] - require(isinstance(method, str) and method in MAX_SIGNING_METHODS, "invalid_request", "unsupported signing method") - require(isinstance(params, list) and len(params) <= 8, "invalid_request", "invalid signing params") - params_json = json.dumps(params, separators=(",", ":")) - require(len(params_json.encode("utf-8")) <= MAX_SIGNING_PARAMS_BYTES, "limit_exceeded", "signing params are too large") - request_id = f"sig_{uuid.uuid4().hex[:16]}" - with self.signing_lock: - self._cleanup_signing_locked() - require(len(self.pending_signing) < MAX_PENDING_SIGNING, "limit_exceeded", "too many pending signing requests") - self.pending_signing[request_id] = {"method": method, "params": params, "created_at": time.monotonic(), "resolved": None} - return {"requestId": request_id} - - def _signing_status(self, request: dict[str, Any]) -> dict[str, Any]: - request_id = request["requestId"] - require(isinstance(request_id, str) and len(request_id) <= 64, "invalid_request", "invalid request id") - with self.signing_lock: - self._cleanup_signing_locked() - entry = self.pending_signing.get(request_id) - if entry is None: - raise ControlError("not_found", "signing request not found") - if entry["resolved"] is None: - return {"status": "pending"} - return entry["resolved"] - - def _signing_pending(self) -> dict[str, Any]: - with self.signing_lock: - self._cleanup_signing_locked() - requests = [ - {"requestId": rid, "method": entry["method"], "params": entry["params"]} - for rid, entry in self.pending_signing.items() - if entry["resolved"] is None - ] - return {"requests": requests} - - def _signing_resolve(self, request: dict[str, Any]) -> dict[str, Any]: - request_id = request["requestId"] - result = request.get("result") - error = request.get("error") - require(isinstance(request_id, str) and len(request_id) <= 64, "invalid_request", "invalid request id") - require(result is not None or error is not None, "invalid_request", "either result or error is required") - if result is not None: - result_json = json.dumps(result, separators=(",", ":")) - require(len(result_json.encode("utf-8")) <= MAX_SIGNING_RESULT_BYTES, "limit_exceeded", "signing result is too large") - with self.signing_lock: - entry = self.pending_signing.get(request_id) - if entry is None or entry["resolved"] is not None: - raise ControlError("not_found", "signing request not found or already resolved") - if error is not None: - entry["resolved"] = {"status": "rejected", "error": str(error)[:1024]} - else: - entry["resolved"] = {"status": "resolved", "result": result} - entry["resolved_at"] = time.monotonic() + def _bloom_ipc(self, method: str, params: dict[str, Any]) -> Any: + """Call bloom serve's IPC socket.""" + require(os.path.exists(BLOOM_SOCKET_PATH), "unavailable", "Bloom IPC socket is not available") + request = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + frame = json.dumps(request, separators=(",", ":")).encode() + b"\n" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(10.0) + client.connect(BLOOM_SOCKET_PATH) + client.sendall(frame) + response = bytearray() + while b"\n" not in response: + chunk = client.recv(65536) + if not chunk: + break + response.extend(chunk) + if len(response) > 1024 * 1024: + break + decoded = json.loads(bytes(response).decode()) + if "error" in decoded: + raise ControlError("internal", f"bloom IPC error: {decoded['error']}") + return decoded.get("result") + + def _outbox_pending(self) -> dict[str, Any]: + """List pending outbox transactions by scanning the bloom VFS via IPC.""" + wallet_result = self._bloom_ipc("list", {"path": "/wallets"}) + wallets = [e.get("name") for e in (wallet_result.get("entries") or []) if e.get("type") == "dir"] + pending = [] + for wallet in wallets[:MAX_OUTBOX_CHAINS]: + chain_result = self._bloom_ipc("list", {"path": f"/wallets/{wallet}/chains"}) + chains = [e.get("name") for e in (chain_result.get("entries") or []) if e.get("type") == "dir"] + for chain in chains[:MAX_OUTBOX_CHAINS]: + pending_result = self._bloom_ipc("list", {"path": f"/wallets/{wallet}/chains/{chain}/outbox/pending"}) + ids = [e.get("name") for e in (pending_result.get("entries") or []) if e.get("type") == "dir"] + for tx_id in ids[:MAX_OUTBOX_PENDING]: + plan_result = self._bloom_ipc("read", {"path": f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/plan.md"}) + plan_b64 = plan_result.get("bytes_b64", "") + plan_md = base64.b64decode(plan_b64).decode("utf-8", errors="replace")[:MAX_OUTBOX_PLAN_BYTES] + pending.append({"id": tx_id, "chain": chain, "wallet": wallet, "planMd": plan_md}) + return {"requests": pending} + + def _outbox_confirm(self, request: dict[str, Any]) -> dict[str, Any]: + """Write confirm to bloom's outbox via IPC.""" + tx_id = request["txId"] + chain = request["chain"] + wallet = request["wallet"] + confirm_text = request["confirmText"] + require(isinstance(tx_id, str) and len(tx_id) <= 64, "invalid_request", "invalid tx id") + require(isinstance(chain, str) and len(chain) <= 32, "invalid_request", "invalid chain") + require(isinstance(wallet, str) and len(wallet) <= 64, "invalid_request", "invalid wallet name") + require(isinstance(confirm_text, str) and len(confirm_text.strip()) > 0, "invalid_request", "confirm text is required") + path = f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/confirm" + body_b64 = base64.b64encode(confirm_text.encode()).decode() + self._bloom_ipc("write", {"path": path, "bytes_b64": body_b64}) + return {} + + def _outbox_cancel(self, request: dict[str, Any]) -> dict[str, Any]: + """Write cancel to bloom's outbox via IPC.""" + tx_id = request["txId"] + chain = request["chain"] + wallet = request["wallet"] + require(isinstance(tx_id, str) and len(tx_id) <= 64, "invalid_request", "invalid tx id") + require(isinstance(chain, str) and len(chain) <= 32, "invalid_request", "invalid chain") + require(isinstance(wallet, str) and len(wallet) <= 64, "invalid_request", "invalid wallet name") + path = f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/cancel" + body_b64 = base64.b64encode(b"cancel").decode() + self._bloom_ipc("write", {"path": path, "bytes_b64": body_b64}) return {} def _configure_connections(self, request: dict[str, Any]) -> dict[str, Any]: diff --git a/ops/guest-control/bloom-workspace b/ops/guest-control/bloom-workspace index f2e977c..7a3e2e9 100755 --- a/ops/guest-control/bloom-workspace +++ b/ops/guest-control/bloom-workspace @@ -88,10 +88,6 @@ def main() -> int: jobs_cancel = jobs.add_parser("cancel") jobs_cancel.add_argument("job_id") - sign = commands.add_parser("sign", help="request a wallet signature through the browser relay") - sign.add_argument("method", choices=["personal_sign", "eth_sendTransaction", "eth_signTypedData_v4"]) - sign.add_argument("params", nargs="?", help="JSON-encoded params array (default: read from stdin)") - sign.add_argument("--timeout", type=int, default=120, help="timeout in seconds (default: 120)") args = parser.parse_args() if args.command == "status": @@ -154,28 +150,6 @@ def main() -> int: write_json(request("job.status", jobId=args.job_id, logOffset=args.cursor, maxBytes=MAX_CHUNK_BYTES)) elif args.jobs_command == "cancel": write_json(request("job.cancel", jobId=args.job_id)) - elif args.command == "sign": - import time as _time - if args.params is not None: - params_raw = args.params - else: - params_raw = sys.stdin.read().strip() - try: - params = json.loads(params_raw) - except json.JSONDecodeError as error: - raise SystemExit(f"invalid JSON params: {error}") from None - if not isinstance(params, list): - raise SystemExit("params must be a JSON array") - result = request("signing.request", method=args.method, params=params) - request_id = result["requestId"] - deadline = _time.monotonic() + args.timeout - while _time.monotonic() < deadline: - status = request("signing.status", requestId=request_id) - if status["status"] != "pending": - write_json(status) - return 0 if status["status"] == "resolved" else 1 - _time.sleep(2) - raise SystemExit("signing request timed out") return 0 diff --git a/src/agent/server.ts b/src/agent/server.ts index 4c40715..7ca6a74 100644 --- a/src/agent/server.ts +++ b/src/agent/server.ts @@ -1,5 +1,4 @@ import { createServer } from "node:http"; -import { randomUUID } from "node:crypto"; import { lstat, mkdir, readFile, unlink } from "node:fs/promises"; import { dirname } from "node:path"; import type { Duplex } from "node:stream"; @@ -202,26 +201,26 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { }); } catch (error) { next(error); } }); - app.get("/v1/workspaces/:id/signing/pending", async (request, response, next) => { + app.get("/v1/workspaces/:id/outbox/pending", async (request, response, next) => { try { const call = requireGuest(runtime, request.params.id ?? ""); - const result = await call({ version: 1, id: `signing_pending_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "signing.pending" }, 10_000); + const result = await call({ version: 1, id: `outbox_pending_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.pending" }, 10_000); response.json(result); } catch (error) { next(error); } }); - app.post("/v1/workspaces/:id/signing/request", async (request, response, next) => { + app.post("/v1/workspaces/:id/outbox/confirm", async (request, response, next) => { try { const call = requireGuest(runtime, request.params.id ?? ""); - const body = z.object({ method: z.enum(["eth_sendTransaction", "personal_sign", "eth_signTypedData_v4"]), params: z.array(z.unknown()).min(1).max(8) }).strict().parse(request.body); - const result = await call({ version: 1, id: `signing_req_${randomUUID().replaceAll("-", "").slice(0, 16)}`, operation: "signing.request", method: body.method, params: body.params }, 10_000); - response.status(201).json(result); + const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64), confirmText: z.string().min(1) }).strict().parse(request.body); + const result = await call({ version: 1, id: `outbox_confirm_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.confirm", txId: body.id, chain: body.chain, wallet: body.wallet, confirmText: body.confirmText }, 10_000); + response.json(result); } catch (error) { next(error); } }); - app.post("/v1/workspaces/:id/signing/resolve", async (request, response, next) => { + app.post("/v1/workspaces/:id/outbox/cancel", async (request, response, next) => { try { const call = requireGuest(runtime, request.params.id ?? ""); - const body = z.object({ requestId: z.string().min(1).max(64), result: z.unknown().optional(), error: z.string().min(1).max(1024).optional() }).refine((v) => v.result !== undefined || v.error !== undefined, "either result or error is required").parse(request.body); - const result = await call({ version: 1, id: `signing_res_${randomUUID().replaceAll("-", "").slice(0, 16)}`, operation: "signing.resolve", requestId: body.requestId, ...(body.result !== undefined ? { result: body.result } : {}), ...(body.error !== undefined ? { error: body.error } : {}) }, 10_000); + const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64) }).strict().parse(request.body); + const result = await call({ version: 1, id: `outbox_cancel_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.cancel", txId: body.id, chain: body.chain, wallet: body.wallet }, 10_000); response.json(result); } catch (error) { next(error); } }); diff --git a/src/control/agent-client.ts b/src/control/agent-client.ts index 0321d98..56c489b 100644 --- a/src/control/agent-client.ts +++ b/src/control/agent-client.ts @@ -30,9 +30,9 @@ export class AgentClient { } cancelJob(id: string, jobId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/jobs/${encodeURIComponent(jobId)}`); } bloomStatus(id: string) { return this.request("GET", `/v1/workspaces/${encodeURIComponent(id)}/bloom`); } - signingPending(id: string) { return this.request<{ requests: { requestId: string; method: string; params: unknown[] }[] }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/signing/pending`); } - signingRequest(id: string, method: string, params: unknown[]) { return this.request<{ requestId: string }>("POST", `/v1/workspaces/${encodeURIComponent(id)}/signing/request`, { method, params }); } - signingResolve(id: string, requestId: string, resolution: { result?: unknown; error?: string }) { return this.request>("POST", `/v1/workspaces/${encodeURIComponent(id)}/signing/resolve`, { requestId, ...resolution }); } + outboxPending(id: string) { return this.request<{ requests: { id: string; chain: string; wallet: string; planMd: string }[] }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/outbox/pending`); } + outboxConfirm(id: string, body: { id: string; chain: string; wallet: string; confirmText: string }) { return this.request>("POST", `/v1/workspaces/${encodeURIComponent(id)}/outbox/confirm`, body); } + outboxCancel(id: string, body: { id: string; chain: string; wallet: string }) { return this.request>("POST", `/v1/workspaces/${encodeURIComponent(id)}/outbox/cancel`, body); } connections(id: string) { return this.request<{ connections: Record<"ssh" | "nfs", { status: "available" | "disabled" | "unsupported"; reason: string; instructions: string[] }> }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/connections`); } issueSsh(id: string, body: SshLeaseBody) { return this.request("POST", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh`, body); } revokeSsh(id: string, leaseId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh/${encodeURIComponent(leaseId)}`); } diff --git a/src/control/jobs-guest.test.ts b/src/control/jobs-guest.test.ts index 57c0cda..fc66363 100644 --- a/src/control/jobs-guest.test.ts +++ b/src/control/jobs-guest.test.ts @@ -186,86 +186,6 @@ describe("guest job, file, and Bloom control service", () => { expect(cancelled.status, cancelled.stderr).toBe(0); expect(await harness.waitForTerminal(jobId)).toMatchObject({ state: "cancelled" }); }); - - it("relays signing requests through the full request-pending-resolve lifecycle", async () => { - const harness = await GuestHarness.create(); - - // Request a personal_sign - const req = await harness.request({ operation: "signing.request", method: "personal_sign", params: ["0x48656c6c6f", "0x1111111111111111111111111111111111111111"] }); - expect(req.ok).toBe(true); - const requestId = (req.result as { requestId: string }).requestId; - expect(requestId).toMatch(/^sig_/); - - // Status should show pending - const status = await harness.request({ operation: "signing.status", requestId }); - expect(status).toMatchObject({ ok: true, result: { status: "pending" } }); - - // Pending list should contain it - const pending = await harness.request({ operation: "signing.pending" }); - expect(pending).toMatchObject({ ok: true }); - const pendingReqs = (pending.result as { requests: { requestId: string; method: string }[] }).requests; - expect(pendingReqs).toEqual(expect.arrayContaining([expect.objectContaining({ requestId, method: "personal_sign" })])); - - // Resolve with a result - const resolved = await harness.request({ operation: "signing.resolve", requestId, result: "0xdeadbeef" }); - expect(resolved).toMatchObject({ ok: true, result: {} }); - - // Status should now show resolved - const finalStatus = await harness.request({ operation: "signing.status", requestId }); - expect(finalStatus).toMatchObject({ ok: true, result: { status: "resolved", result: "0xdeadbeef" } }); - - // Pending should no longer contain it - const pendingAfter = await harness.request({ operation: "signing.pending" }); - const pendingReqsAfter = (pendingAfter.result as { requests: { requestId: string }[] }).requests; - expect(pendingReqsAfter.find((r) => r.requestId === requestId)).toBeUndefined(); - }); - - it("rejects unsupported signing methods and enforces param limits", async () => { - const harness = await GuestHarness.create(); - - expect(await harness.request({ operation: "signing.request", method: "eth_sign", params: ["0x1"] })) - .toMatchObject({ ok: false, error: { code: "invalid_request" } }); - - expect(await harness.request({ operation: "signing.request", method: "personal_sign", params: "not-an-array" })) - .toMatchObject({ ok: false, error: { code: "invalid_request" } }); - - const oversizedParams = Array.from({ length: 10 }, (_, i) => `0x${i}`); - expect(await harness.request({ operation: "signing.request", method: "personal_sign", params: oversizedParams })) - .toMatchObject({ ok: false, error: { code: "invalid_request" } }); - }); - - it("prevents resolving a signing request twice", async () => { - const harness = await GuestHarness.create(); - - const req = await harness.request({ operation: "signing.request", method: "personal_sign", params: ["0x48656c6c6f", "0x1111111111111111111111111111111111111111"] }); - const requestId = (req.result as { requestId: string }).requestId; - - const first = await harness.request({ operation: "signing.resolve", requestId, result: "0xabc" }); - expect(first.ok).toBe(true); - - const second = await harness.request({ operation: "signing.resolve", requestId, result: "0xdef" }); - expect(second).toMatchObject({ ok: false, error: { code: "not_found" } }); - }); - - it("allows rejecting a signing request with an error", async () => { - const harness = await GuestHarness.create(); - - const req = await harness.request({ operation: "signing.request", method: "personal_sign", params: ["0x48656c6c6f", "0x1111111111111111111111111111111111111111"] }); - const requestId = (req.result as { requestId: string }).requestId; - - const rejected = await harness.request({ operation: "signing.resolve", requestId, error: "user rejected" }); - expect(rejected).toMatchObject({ ok: true, result: {} }); - - const status = await harness.request({ operation: "signing.status", requestId }); - expect(status).toMatchObject({ ok: true, result: { status: "rejected", error: "user rejected" } }); - }); - - it("returns not_found for unknown signing request IDs", async () => { - const harness = await GuestHarness.create(); - - expect(await harness.request({ operation: "signing.status", requestId: "sig_nonexistent" })) - .toMatchObject({ ok: false, error: { code: "not_found" } }); - }); }); type RequestFields = { operation: string } & Record; diff --git a/src/control/server.ts b/src/control/server.ts index 4d04fec..58f2e20 100644 --- a/src/control/server.ts +++ b/src/control/server.ts @@ -238,21 +238,25 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { response.json(await workspaces.bloomStatus(session.wallet, String(request.params.id ?? ""))); } catch (error) { next(error); } }); - app.get("/api/workspaces/:id/signing/pending", requireSession, async (request, response, next) => { + app.get("/api/workspaces/:id/outbox/pending", requireSession, async (request, response, next) => { try { const { session } = response.locals.context as { session: Session }; - response.json(await workspaces.signingPending(session.wallet, String(request.params.id ?? ""))); + response.json(await workspaces.outboxPending(session.wallet, String(request.params.id ?? ""))); } catch (error) { next(error); } }); - app.post("/api/workspaces/:id/signing/resolve", requireSession, async (request, response, next) => { + app.post("/api/workspaces/:id/outbox/confirm", requireSession, async (request, response, next) => { try { const { session } = response.locals.context as { session: Session }; - const body = z.object({ - requestId: z.string().min(1).max(64), - result: z.unknown().optional(), - error: z.string().min(1).max(1024).optional(), - }).refine((v) => v.result !== undefined || v.error !== undefined, "either result or error is required").parse(request.body); - await workspaces.signingResolve(session.wallet, String(request.params.id ?? ""), body.requestId, { ...(body.result !== undefined ? { result: body.result } : {}), ...(body.error !== undefined ? { error: body.error } : {}) }); + const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64), confirmText: z.string().min(1) }).strict().parse(request.body); + await workspaces.outboxConfirm(session.wallet, String(request.params.id ?? ""), body); + response.status(204).end(); + } catch (error) { next(error); } + }); + app.post("/api/workspaces/:id/outbox/cancel", requireSession, async (request, response, next) => { + try { + const { session } = response.locals.context as { session: Session }; + const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64) }).strict().parse(request.body); + await workspaces.outboxCancel(session.wallet, String(request.params.id ?? ""), body); response.status(204).end(); } catch (error) { next(error); } }); diff --git a/src/control/workspaces.ts b/src/control/workspaces.ts index 0bb7b5c..d8dd867 100644 --- a/src/control/workspaces.ts +++ b/src/control/workspaces.ts @@ -151,17 +151,26 @@ export class WorkspaceService { catch (error) { throw this.agentWorkspaceError(error); } } - async signingPending(wallet: string, id: string) { - const row = this.ownedRunning(wallet, id, "signing_pending"); - try { return await this.agent.signingPending(row.id); } + async outboxPending(wallet: string, id: string) { + const row = this.ownedRunning(wallet, id, "outbox_pending"); + try { return await this.agent.outboxPending(row.id); } catch (error) { throw this.agentWorkspaceError(error); } } - async signingResolve(wallet: string, id: string, requestId: string, resolution: { result?: unknown; error?: string }) { - const row = this.ownedRunning(wallet, id, "signing_resolve"); + async outboxConfirm(wallet: string, id: string, body: { id: string; chain: string; wallet: string; confirmText: string }) { + const row = this.ownedRunning(wallet, id, "outbox_confirm"); try { - const result = await this.agent.signingResolve(row.id, requestId, resolution); - audit(this.db, "workspace.signing_resolved", row.wallet, row.id, { requestId, approved: resolution.error === undefined }); + const result = await this.agent.outboxConfirm(row.id, body); + audit(this.db, "workspace.outbox_confirmed", row.wallet, row.id, { txId: body.id, chain: body.chain, wallet: body.wallet }); + return result; + } catch (error) { throw this.agentWorkspaceError(error); } + } + + async outboxCancel(wallet: string, id: string, body: { id: string; chain: string; wallet: string }) { + const row = this.ownedRunning(wallet, id, "outbox_cancel"); + try { + const result = await this.agent.outboxCancel(row.id, body); + audit(this.db, "workspace.outbox_cancelled", row.wallet, row.id, { txId: body.id, chain: body.chain, wallet: body.wallet }); return result; } catch (error) { throw this.agentWorkspaceError(error); } } diff --git a/src/guest-protocol.ts b/src/guest-protocol.ts index c82f8be..a316782 100644 --- a/src/guest-protocol.ts +++ b/src/guest-protocol.ts @@ -59,19 +59,20 @@ export const GuestRequest = z.discriminatedUnion("operation", [ caPublicKey: z.string().min(32).max(1024).regex(/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/), nfs: z.boolean(), }), + Envelope.extend({ operation: z.literal("outbox.pending") }), Envelope.extend({ - operation: z.literal("signing.request"), - method: z.enum(["eth_sendTransaction", "personal_sign", "eth_signTypedData_v4"]), - params: z.array(z.unknown()).min(1).max(8), + operation: z.literal("outbox.confirm"), + txId: z.string().min(1).max(64), + chain: z.string().min(1).max(32), + wallet: z.string().min(1).max(64), + confirmText: z.string().min(1), }), - Envelope.extend({ operation: z.literal("signing.status"), requestId: z.string().min(1).max(64) }), - Envelope.extend({ operation: z.literal("signing.pending") }), Envelope.extend({ - operation: z.literal("signing.resolve"), - requestId: z.string().min(1).max(64), - result: z.unknown().optional(), - error: z.string().min(1).max(1024).optional(), - }).refine((v) => v.result !== undefined || v.error !== undefined, "either result or error is required"), + operation: z.literal("outbox.cancel"), + txId: z.string().min(1).max(64), + chain: z.string().min(1).max(32), + wallet: z.string().min(1).max(64), + }), ]); export type GuestRequest = z.infer; diff --git a/web/index.html b/web/index.html index f95b6ea..cf18437 100644 --- a/web/index.html +++ b/web/index.html @@ -174,13 +174,13 @@

Workspace console

- + -

Signing request

-

Approve wallet request?

-

-

-        
+

Outbox transaction

+

Review Transaction

+

+

+        
diff --git a/web/main.ts b/web/main.ts index 966b528..72bcf2f 100644 --- a/web/main.ts +++ b/web/main.ts @@ -105,7 +105,7 @@ let fit: FitAddon | undefined; let workspacePollTimer: number | undefined; let leaseTimer: number | undefined; let jobPollTimer: number | undefined; -let signingPollTimer: number | undefined; +let outboxPollTimer: number | undefined; let jobEvents: EventSource | undefined; let activeJob: JobStatus | undefined; let activeConnection: ConnectionGrant | undefined; @@ -470,7 +470,7 @@ function renderWorkspace(workspace?: Workspace) { consoleElement.classList.remove("hidden"); if (loadedWorkspaceId !== workspace.id) resetWorkspaceTools(workspace.id); if (isAvailable("terminal") && !socket) openTerminal(workspace); - if (!signingPollTimer) startSigningPoll(workspace.id); + if (!outboxPollTimer) startOutboxPoll(workspace.id); } updateLease(workspace); workspacePollTimer = window.setTimeout(refreshWorkspace, workspace.state === "running" ? 10_000 : 1_500); @@ -515,7 +515,7 @@ function resetWorkspaceTools(id: string) { function closeWorkspaceResources() { closeTerminal(); closeJobStream(); - closeSigningPoll(); + closeOutboxPoll(); clearConnectionGrant(); loadedWorkspaceId = undefined; activeJob = undefined; @@ -523,83 +523,51 @@ function closeWorkspaceResources() { window.clearTimeout(leaseTimer); } -function closeSigningPoll() { - window.clearInterval(signingPollTimer); - signingPollTimer = undefined; +function closeOutboxPoll() { + window.clearInterval(outboxPollTimer); + outboxPollTimer = undefined; } -function formatSigningRequest(method: string, params: unknown[]): string { - if (method === "personal_sign") { - const message = String(params[0] ?? ""); - const address = String(params[1] ?? ""); - return `Method: personal_sign\nFrom: ${address}\nMessage:\n${message}`; - } - if (method === "eth_sendTransaction") { - const tx = (params[0] ?? {}) as Record; - const from = String(tx.from ?? "?"); - const to = String(tx.to ?? "?"); - const value = String(tx.value ?? "0x0"); - const data = String(tx.data ?? "0x"); - const lines = [`Method: eth_sendTransaction`, `From: ${from}`, `To: ${to}`, `Value: ${value} wei`]; - if (data !== "0x" && data.length > 10) lines.push(`Data: ${data.slice(0, 100)}${data.length > 100 ? "…" : ""}`); - return lines.join("\n"); - } - if (method === "eth_signTypedData_v4") { - const address = String(params[0] ?? ""); - const typed = params[1]; - return `Method: eth_signTypedData_v4\nFrom: ${address}\nTyped data:\n${typeof typed === "string" ? typed.slice(0, 500) : JSON.stringify(typed, null, 2).slice(0, 500)}`; - } - return `Method: ${method}\nParams: ${JSON.stringify(params).slice(0, 500)}`; -} - -function startSigningPoll(workspaceId: string) { - closeSigningPoll(); +function startOutboxPoll(workspaceId: string) { + closeOutboxPoll(); const seen = new Set(); - signingPollTimer = window.setInterval(async () => { + outboxPollTimer = window.setInterval(async () => { if (!walletBinding || !session.authenticated) return; try { - const result = await api<{ requests: { requestId: string; method: string; params: unknown[] }[] }>(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/pending`); + const result = await api<{ requests: { id: string; chain: string; wallet: string; planMd: string }[] }>(`/api/workspaces/${encodeURIComponent(workspaceId)}/outbox/pending`); for (const request of result.requests) { - if (seen.has(request.requestId)) continue; - seen.add(request.requestId); - void promptAndResolveSigningRequest(workspaceId, request); + if (seen.has(request.id)) continue; + seen.add(request.id); + void promptAndResolveOutboxRequest(workspaceId, request); } - if (seen.size > 50) { const keep = new Set(result.requests.map((r) => r.requestId)); for (const id of seen) if (!keep.has(id)) seen.delete(id); } + if (seen.size > 50) { const keep = new Set(result.requests.map((r) => r.id)); for (const id of seen) if (!keep.has(id)) seen.delete(id); } } catch { /* workspace may have stopped */ } }, 3_000); } -async function promptAndResolveSigningRequest(workspaceId: string, request: { requestId: string; method: string; params: unknown[] }) { +async function promptAndResolveOutboxRequest(workspaceId: string, request: { id: string; chain: string; wallet: string; planMd: string }) { if (!walletBinding) return; - const dialog = $("signing-dialog") as HTMLDialogElement; - const description = $("signing-description"); - const detail = $("signing-detail"); - description.textContent = `Your workspace is requesting a wallet signature. Review the details below, then approve or reject.`; - detail.textContent = formatSigningRequest(request.method, request.params); + const dialog = $("outbox-dialog") as HTMLDialogElement; + const description = $("outbox-description"); + const detail = $("outbox-detail"); + description.textContent = `Your workspace has a pending transaction for review. Review the plan below, then approve or reject.`; + detail.textContent = request.planMd; dialog.showModal(); const choice = await new Promise((resolve) => { const onClose = () => { dialog.removeEventListener("close", onClose); resolve(dialog.returnValue || "reject"); }; dialog.addEventListener("close", onClose); }); if (choice !== "approve") { - await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/resolve`, { + await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/outbox/cancel`, { method: "POST", - body: JSON.stringify({ requestId: request.requestId, error: "User rejected the signing request" }), + body: JSON.stringify({ id: request.id, chain: request.chain, wallet: request.wallet }), }).catch(() => undefined); return; } - try { - const result = await (walletBinding.provider as { request(args: { method: string; params: unknown[] }): Promise }).request({ method: request.method, params: request.params }); - await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/resolve`, { - method: "POST", - body: JSON.stringify({ requestId: request.requestId, result }), - }); - } catch (error) { - await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/signing/resolve`, { - method: "POST", - body: JSON.stringify({ requestId: request.requestId, error: error instanceof Error ? error.message : "Wallet request failed" }), - }).catch(() => undefined); - } + await api(`/api/workspaces/${encodeURIComponent(workspaceId)}/outbox/confirm`, { + method: "POST", + body: JSON.stringify({ id: request.id, chain: request.chain, wallet: request.wallet, confirmText: "y" }), + }).catch(() => undefined); } function openTerminal(workspace: Workspace) { diff --git a/web/style.css b/web/style.css index fd01c22..d352c5d 100644 --- a/web/style.css +++ b/web/style.css @@ -171,7 +171,7 @@ dialog::backdrop { background: #000a; backdrop-filter: blur(3px); } .dialog-card { padding: 24px; } .dialog-card h2 { font-size: 24px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; } -.signing-detail { background: #070a08; border: 1px solid var(--line); border-radius: 8px; padding: 12px; font: 12px ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; max-height: 240px; overflow-y: auto; margin: 12px 0 0; color: var(--muted); } +.outbox-detail { background: #070a08; border: 1px solid var(--line); border-radius: 8px; padding: 12px; font: 12px ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; max-height: 240px; overflow-y: auto; margin: 12px 0 0; color: var(--muted); } @media (max-width: 980px) { .workspace-panel { grid-template-columns: 1fr; } From deae15e502d61ec819f389acdf1277ce1489a317 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:14:28 +0300 Subject: [PATCH 05/10] feat: make workspace storage quota operator-configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 128 MiB default was hardcoded in three places (db.ts schema, db.ts migration, workspaces.ts constant) with no env var or config path. This made the quota impossible to tune without code changes. - Add BLOOM_STORAGE_QUOTA_MIB env var (16–5120 MiB, default 512) - Wire config.storageQuotaBytes through WorkspaceService - Remove hardcoded DEFAULT_STORAGE_QUOTA_BYTES constant - Update DB schema and migration defaults to 512 MiB (536870912) - Update Python guest controller default to match - Remove hardcoded '128 MiB' text from web UI (actual quota is already shown dynamically via formatBytes) - Add config test for the new option - Update workspace-data-api test expectation - Add __pycache__/ to .gitignore --- .env.example | 2 ++ .gitignore | 1 + ops/guest-control/bloom-guest-control.py | 2 +- src/config.test.ts | 7 +++++++ src/config.ts | 2 ++ src/control/workspace-data-api.test.ts | 2 +- src/control/workspaces.ts | 5 ++--- src/db.ts | 4 ++-- web/index.html | 2 +- web/main.ts | 2 +- 10 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index a08660c..df7db44 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,8 @@ BLOOM_SSH_ENABLED=0 # BLOOM_SSH_MAX_LEASE_MINUTES=15 BLOOM_NFS_ENABLED=0 # BLOOM_NFS_KERNEL_CONFIG=./artifacts/nfs-kernel/vmlinux-6.1.155-nfsd.config +# Storage quota for workspace volumes in MiB (16–5120, default 512). +# BLOOM_STORAGE_QUOTA_MIB=512 # Set controlled for the curated public package allowlist. Raw internet mode is # rejected in public deployments. # BLOOM_VM_EGRESS=controlled diff --git a/.gitignore b/.gitignore index 4c52d4b..96c6e0d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ artifacts/ *.log .env .DS_Store +__pycache__/ diff --git a/ops/guest-control/bloom-guest-control.py b/ops/guest-control/bloom-guest-control.py index 8e9a13a..b896e09 100755 --- a/ops/guest-control/bloom-guest-control.py +++ b/ops/guest-control/bloom-guest-control.py @@ -1076,7 +1076,7 @@ def serve_sockets(control: GuestControl, listeners: list[socket.socket]) -> None def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Bloom workspace guest control service") parser.add_argument("--workspace", default="/workspace") - parser.add_argument("--workspace-quota-bytes", type=int, default=128 * 1024 * 1024) + parser.add_argument("--workspace-quota-bytes", type=int, default=512 * 1024 * 1024) parser.add_argument("--job-uid", type=int, default=1000) parser.add_argument("--job-gid", type=int, default=1000) parser.add_argument("--stdio", action="store_true") diff --git a/src/config.test.ts b/src/config.test.ts index b15f01e..b84fde3 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -79,6 +79,13 @@ describe("public configuration guardrails", () => { expect(loadConfig({}).persistenceEnabled).toBe(true); }); + it("defaults storage quota to 512 MiB and respects operator override", () => { + expect(loadConfig({}).storageQuotaBytes).toBe(512 * 1024 * 1024); + expect(loadConfig({ BLOOM_STORAGE_QUOTA_MIB: "2048" }).storageQuotaBytes).toBe(2 * 1024 * 1024 * 1024); + expect(() => loadConfig({ BLOOM_STORAGE_QUOTA_MIB: "8" })).toThrow(); + expect(() => loadConfig({ BLOOM_STORAGE_QUOTA_MIB: "8192" })).toThrow(); + }); + it("requires explicit SSH/NFS prerequisites in public mode", () => { const base = { BLOOM_PUBLIC_MODE: "1", BLOOM_ORIGIN: "https://workspaces.example.com", BLOOM_RUNTIME: "qemu", diff --git a/src/config.ts b/src/config.ts index 24b82ce..2eb44f5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,7 @@ const Env = z.object({ BLOOM_SSH_MAX_LEASE_MINUTES: z.coerce.number().int().min(1).max(120).default(15), BLOOM_NFS_ENABLED: booleanString, BLOOM_NFS_KERNEL_CONFIG: z.string().optional(), + BLOOM_STORAGE_QUOTA_MIB: z.coerce.number().int().min(16).max(5120).default(512), }); export type Config = ReturnType; @@ -110,6 +111,7 @@ export function loadConfig(source: NodeJS.ProcessEnv = process.env, role: "all" sshMaxLeaseMs: env.BLOOM_SSH_MAX_LEASE_MINUTES * 60_000, nfsEnabled: env.BLOOM_NFS_ENABLED, nfsKernelConfig: env.BLOOM_NFS_KERNEL_CONFIG ? resolve(env.BLOOM_NFS_KERNEL_CONFIG) : undefined, + storageQuotaBytes: env.BLOOM_STORAGE_QUOTA_MIB * 1024 * 1024, sessionTtlMs: 12 * 60 * 60_000, challengeTtlMs: 5 * 60_000, agentRequestTimeoutMs: 30_000, diff --git a/src/control/workspace-data-api.test.ts b/src/control/workspace-data-api.test.ts index d879039..478d5ee 100644 --- a/src/control/workspace-data-api.test.ts +++ b/src/control/workspace-data-api.test.ts @@ -48,7 +48,7 @@ describe("authenticated workspace data API", () => { const ownerCsrf = login.body.csrfToken as string; const created = await request(control.app).post("/api/workspaces") .set(ownerHeaders(config.origin, ownerCookie, ownerCsrf)).send({ storage: "persistent" }).expect(202); - expect(created.body.workspace.storage).toMatchObject({ mode: "persistent", quotaBytes: 128 * 1024 * 1024, retainedAfterStop: true }); + expect(created.body.workspace.storage).toMatchObject({ mode: "persistent", quotaBytes: 512 * 1024 * 1024, retainedAfterStop: true }); const firstId = created.body.workspace.id as string; await eventually(async () => (await ownerGet(control.app, ownerCookie, "/api/workspaces/current")).body.workspace?.state === "running"); diff --git a/src/control/workspaces.ts b/src/control/workspaces.ts index d8dd867..92063c7 100644 --- a/src/control/workspaces.ts +++ b/src/control/workspaces.ts @@ -8,7 +8,6 @@ import type { StructuredJobSpec } from "../jobs/model.js"; import type { SshLeaseBody } from "../ssh/api.js"; const ACTIVE = "'queued','provisioning','running','stopping'"; -export const DEFAULT_STORAGE_QUOTA_BYTES = 128 * 1024 * 1024; export class WorkspaceError extends Error { constructor(message: string, readonly status = 400) { super(message); } @@ -48,7 +47,7 @@ export class WorkspaceService { const row: WorkspaceRow = { id: randomUUID(), wallet: normalizedWallet, ip_hash: ipHash, state: "queued", runtime: this.config.runtime, created_at: now, lease_expires_at: now + this.config.leaseMs, stopped_at: null, failure: null, - storage_mode: storageMode, volume_id: volume?.id ?? null, storage_quota_bytes: volume?.quota_bytes ?? DEFAULT_STORAGE_QUOTA_BYTES, + storage_mode: storageMode, volume_id: volume?.id ?? null, storage_quota_bytes: volume?.quota_bytes ?? this.config.storageQuotaBytes, }; this.db.prepare("INSERT INTO workspaces (id, wallet, ip_hash, state, runtime, created_at, lease_expires_at, storage_mode, volume_id, storage_quota_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") .run(row.id, row.wallet, row.ip_hash, row.state, row.runtime, row.created_at, row.lease_expires_at, row.storage_mode, row.volume_id, row.storage_quota_bytes); @@ -281,7 +280,7 @@ export class WorkspaceService { return { ...existing, last_attached_at: now }; } const volume: PersistentVolumeRow = { - id: randomUUID(), wallet, quota_bytes: DEFAULT_STORAGE_QUOTA_BYTES, created_at: now, last_attached_at: now, destroyed_at: null, + id: randomUUID(), wallet, quota_bytes: this.config.storageQuotaBytes, created_at: now, last_attached_at: now, destroyed_at: null, }; this.db.prepare("INSERT INTO persistent_volumes (id, wallet, quota_bytes, created_at, last_attached_at) VALUES (?, ?, ?, ?, ?)") .run(volume.id, volume.wallet, volume.quota_bytes, volume.created_at, volume.last_attached_at); diff --git a/src/db.ts b/src/db.ts index c006919..28c398d 100644 --- a/src/db.ts +++ b/src/db.ts @@ -81,7 +81,7 @@ export function openDatabase(path: string) { failure TEXT, storage_mode TEXT NOT NULL DEFAULT 'disposable' CHECK (storage_mode IN ('disposable','persistent')), volume_id TEXT REFERENCES persistent_volumes(id), - storage_quota_bytes INTEGER NOT NULL DEFAULT 134217728 CHECK (storage_quota_bytes > 0) + storage_quota_bytes INTEGER NOT NULL DEFAULT 536870912 CHECK (storage_quota_bytes > 0) ) STRICT; CREATE INDEX IF NOT EXISTS workspaces_wallet ON workspaces(wallet, created_at DESC); CREATE INDEX IF NOT EXISTS workspaces_state ON workspaces(state, created_at); @@ -112,5 +112,5 @@ function migrateWorkspaceStorage(db: DatabaseSync) { const columns = new Set((db.prepare("PRAGMA table_info(workspaces)").all() as unknown as Array<{ name: string }>).map((column) => column.name)); if (!columns.has("storage_mode")) db.exec("ALTER TABLE workspaces ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'disposable' CHECK (storage_mode IN ('disposable','persistent'))"); if (!columns.has("volume_id")) db.exec("ALTER TABLE workspaces ADD COLUMN volume_id TEXT REFERENCES persistent_volumes(id)"); - if (!columns.has("storage_quota_bytes")) db.exec("ALTER TABLE workspaces ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 134217728 CHECK (storage_quota_bytes > 0)"); + if (!columns.has("storage_quota_bytes")) db.exec("ALTER TABLE workspaces ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 536870912 CHECK (storage_quota_bytes > 0)"); } diff --git a/web/index.html b/web/index.html index cf18437..f8a32d7 100644 --- a/web/index.html +++ b/web/index.html @@ -49,7 +49,7 @@

Ready when you are

diff --git a/web/main.ts b/web/main.ts index 72bcf2f..6d56540 100644 --- a/web/main.ts +++ b/web/main.ts @@ -414,7 +414,7 @@ function renderCapabilities() { persistentInput.disabled = persistence.status !== "available"; if (persistentInput.disabled && persistentInput.checked) $('input[name="storage"][value="disposable"]').checked = true; $("persistent-choice").classList.toggle("choice-disabled", persistentInput.disabled); - $("persistent-choice").setAttribute("title", persistentInput.disabled ? persistence.reason : "128 MiB retained after the machine stops"); + $("persistent-choice").setAttribute("title", persistentInput.disabled ? persistence.reason : "Persistent volume retained after the machine stops"); syncCapabilityControls(); } From 48ab6d35946c33e6c7ad47061fd25d2185159285 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:38:24 +0300 Subject: [PATCH 06/10] fix: block path traversal in outbox endpoints + eliminate redundant directory walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: outbox confirm/cancel endpoints accepted arbitrary strings for txId, chain, and wallet fields — only length-validated. These values are interpolated into Bloom VFS paths (e.g. /wallets/{wallet}/chains/{chain}/ outbox/pending/{txId}/confirm). A crafted value like '../../' could traverse the VFS tree. Now enforced at all four layers: guest protocol Zod schema, control server, agent server, and Python guest controller. Performance: file write handler walked the entire workspace directory tree twice per write — once before writing (quota check) and once after (usage report). The post-write walk was redundant since we already know the delta from the pre-write walk + file stat. Replaced with arithmetic. Tests: added path traversal rejection tests to guest-protocol.test.ts. --- ops/guest-control/bloom-guest-control.py | 27 +++++++++++++++++------- src/agent/server.ts | 4 ++-- src/control/server.ts | 4 ++-- src/guest-protocol.test.ts | 19 +++++++++++++++++ src/guest-protocol.ts | 16 ++++++++------ 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/ops/guest-control/bloom-guest-control.py b/ops/guest-control/bloom-guest-control.py index b896e09..67b4bd1 100755 --- a/ops/guest-control/bloom-guest-control.py +++ b/ops/guest-control/bloom-guest-control.py @@ -56,6 +56,7 @@ ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") EVM_ADDRESS = re.compile(r"^0x[0-9a-f]{40}$") SSH_CA_PUBLIC_KEY = re.compile(r"^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$") +OUTBOX_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") WORKSPACE_ID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") USER_ENV_EXACT = frozenset( { @@ -308,12 +309,11 @@ def write(self, path: Any, offset: Any, encoded: Any, truncate: Any) -> dict[str final_size = os.fstat(descriptor).st_size finally: os.close(descriptor) - used_after = self._directory_bytes() return { "path": "/".join(parts), "size": final_size, "nextOffset": start + len(chunk), - "usedBytes": used_after, + "usedBytes": used - prior + resulting_size, "quotaBytes": self.quota_bytes, } @@ -803,6 +803,17 @@ def _bloom_status(self) -> dict[str, Any]: "helper": {"name": "bloom-workspace", "protocolVersion": PROTOCOL_VERSION}, } + @staticmethod + def _validate_outbox_identifier(value: Any, label: str, max_len: int = 64) -> None: + """Validate that an outbox identifier is safe for VFS path interpolation.""" + require( + isinstance(value, str) + and 1 <= len(value) <= max_len + and OUTBOX_ID.fullmatch(value) is not None, + "invalid_request", + f"invalid {label}", + ) + def _bloom_ipc(self, method: str, params: dict[str, Any]) -> Any: """Call bloom serve's IPC socket.""" require(os.path.exists(BLOOM_SOCKET_PATH), "unavailable", "Bloom IPC socket is not available") @@ -849,9 +860,9 @@ def _outbox_confirm(self, request: dict[str, Any]) -> dict[str, Any]: chain = request["chain"] wallet = request["wallet"] confirm_text = request["confirmText"] - require(isinstance(tx_id, str) and len(tx_id) <= 64, "invalid_request", "invalid tx id") - require(isinstance(chain, str) and len(chain) <= 32, "invalid_request", "invalid chain") - require(isinstance(wallet, str) and len(wallet) <= 64, "invalid_request", "invalid wallet name") + self._validate_outbox_identifier(tx_id, "tx id") + self._validate_outbox_identifier(chain, "chain", max_len=32) + self._validate_outbox_identifier(wallet, "wallet") require(isinstance(confirm_text, str) and len(confirm_text.strip()) > 0, "invalid_request", "confirm text is required") path = f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/confirm" body_b64 = base64.b64encode(confirm_text.encode()).decode() @@ -863,9 +874,9 @@ def _outbox_cancel(self, request: dict[str, Any]) -> dict[str, Any]: tx_id = request["txId"] chain = request["chain"] wallet = request["wallet"] - require(isinstance(tx_id, str) and len(tx_id) <= 64, "invalid_request", "invalid tx id") - require(isinstance(chain, str) and len(chain) <= 32, "invalid_request", "invalid chain") - require(isinstance(wallet, str) and len(wallet) <= 64, "invalid_request", "invalid wallet name") + self._validate_outbox_identifier(tx_id, "tx id") + self._validate_outbox_identifier(chain, "chain", max_len=32) + self._validate_outbox_identifier(wallet, "wallet") path = f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/cancel" body_b64 = base64.b64encode(b"cancel").decode() self._bloom_ipc("write", {"path": path, "bytes_b64": body_b64}) diff --git a/src/agent/server.ts b/src/agent/server.ts index 7ca6a74..0556dd9 100644 --- a/src/agent/server.ts +++ b/src/agent/server.ts @@ -211,7 +211,7 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { app.post("/v1/workspaces/:id/outbox/confirm", async (request, response, next) => { try { const call = requireGuest(runtime, request.params.id ?? ""); - const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64), confirmText: z.string().min(1) }).strict().parse(request.body); + const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), confirmText: z.string().min(1) }).strict().parse(request.body); const result = await call({ version: 1, id: `outbox_confirm_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.confirm", txId: body.id, chain: body.chain, wallet: body.wallet, confirmText: body.confirmText }, 10_000); response.json(result); } catch (error) { next(error); } @@ -219,7 +219,7 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { app.post("/v1/workspaces/:id/outbox/cancel", async (request, response, next) => { try { const call = requireGuest(runtime, request.params.id ?? ""); - const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64) }).strict().parse(request.body); + const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/) }).strict().parse(request.body); const result = await call({ version: 1, id: `outbox_cancel_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.cancel", txId: body.id, chain: body.chain, wallet: body.wallet }, 10_000); response.json(result); } catch (error) { next(error); } diff --git a/src/control/server.ts b/src/control/server.ts index 58f2e20..ff632aa 100644 --- a/src/control/server.ts +++ b/src/control/server.ts @@ -247,7 +247,7 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { app.post("/api/workspaces/:id/outbox/confirm", requireSession, async (request, response, next) => { try { const { session } = response.locals.context as { session: Session }; - const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64), confirmText: z.string().min(1) }).strict().parse(request.body); + const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), confirmText: z.string().min(1) }).strict().parse(request.body); await workspaces.outboxConfirm(session.wallet, String(request.params.id ?? ""), body); response.status(204).end(); } catch (error) { next(error); } @@ -255,7 +255,7 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { app.post("/api/workspaces/:id/outbox/cancel", requireSession, async (request, response, next) => { try { const { session } = response.locals.context as { session: Session }; - const body = z.object({ id: z.string().min(1).max(64), chain: z.string().min(1).max(32), wallet: z.string().min(1).max(64) }).strict().parse(request.body); + const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/) }).strict().parse(request.body); await workspaces.outboxCancel(session.wallet, String(request.params.id ?? ""), body); response.status(204).end(); } catch (error) { next(error); } diff --git a/src/guest-protocol.test.ts b/src/guest-protocol.test.ts index 28bc687..c2ce3ec 100644 --- a/src/guest-protocol.test.ts +++ b/src/guest-protocol.test.ts @@ -51,6 +51,25 @@ describe("guest control protocol", () => { expect(GuestRequest.safeParse({ ...request, caPublicKey: "-----BEGIN OPENSSH PRIVATE KEY-----" }).success).toBe(false); }); + it("rejects path traversal in outbox confirm and cancel identifiers", () => { + const validConfirm = { ...envelope, operation: "outbox.confirm", txId: "tx_abc123", chain: "8453", wallet: "my-wallet", confirmText: "confirmed" }; + const validCancel = { ...envelope, operation: "outbox.cancel", txId: "tx_abc123", chain: "8453", wallet: "my-wallet" }; + expect(GuestRequest.safeParse(validConfirm).success).toBe(true); + expect(GuestRequest.safeParse(validCancel).success).toBe(true); + + // Path traversal payloads must be rejected at the protocol layer + for (const traversal of ["../../..", "foo/bar", "..", ".", "a\\b", "x\0y"]) { + const confirmPayload = { ...envelope, operation: "outbox.confirm", txId: traversal, chain: "8453", wallet: "ok", confirmText: "ok" }; + const cancelPayload = { ...envelope, operation: "outbox.cancel", txId: "ok", chain: traversal, wallet: "ok" }; + expect(GuestRequest.safeParse(confirmPayload).success, `outbox.confirm txId="${traversal}"`).toBe(false); + expect(GuestRequest.safeParse(cancelPayload).success, `outbox.cancel chain="${traversal}"`).toBe(false); + } + + // Empty strings must be rejected + expect(GuestRequest.safeParse({ ...validConfirm, txId: "" }).success).toBe(false); + expect(GuestRequest.safeParse({ ...validCancel, wallet: "" }).success).toBe(false); + }); + it("frames partial and multiple responses without unbounded buffering", () => { const first = encodeGuestFrame({ ...envelope, operation: "hello" }); const second = encodeGuestFrame({ ...envelope, id: "request_2", operation: "bloom.status" }); diff --git a/src/guest-protocol.ts b/src/guest-protocol.ts index a316782..3b9bbf4 100644 --- a/src/guest-protocol.ts +++ b/src/guest-protocol.ts @@ -7,6 +7,10 @@ export const MAX_FILE_CHUNK_BYTES = 256 * 1024; export const MAX_JOB_LOG_CHUNK_BYTES = 256 * 1024; const RequestId = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); +/** Safe identifier for Bloom outbox VFS paths — blocks path traversal characters. */ +const OutboxId = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); +const OutboxChain = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/); +const OutboxWallet = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); const RelativePath = z.string().min(1).max(1024).refine(isSafeWorkspacePath, "unsafe workspace path"); const WorkspaceDirectory = z.union([z.literal("."), RelativePath]); const Base64Chunk = z.string().max(Math.ceil(MAX_FILE_CHUNK_BYTES / 3) * 4 + 4) @@ -62,16 +66,16 @@ export const GuestRequest = z.discriminatedUnion("operation", [ Envelope.extend({ operation: z.literal("outbox.pending") }), Envelope.extend({ operation: z.literal("outbox.confirm"), - txId: z.string().min(1).max(64), - chain: z.string().min(1).max(32), - wallet: z.string().min(1).max(64), + txId: OutboxId, + chain: OutboxChain, + wallet: OutboxWallet, confirmText: z.string().min(1), }), Envelope.extend({ operation: z.literal("outbox.cancel"), - txId: z.string().min(1).max(64), - chain: z.string().min(1).max(32), - wallet: z.string().min(1).max(64), + txId: OutboxId, + chain: OutboxChain, + wallet: OutboxWallet, }), ]); From d443c3868a84808f9dffd3bea4068df0b2311c1c Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:43:03 +0300 Subject: [PATCH 07/10] feat: add disk space checks, structured request logging, and metrics endpoint Reliability: ensureExt4Volume now checks available disk space via statfs before allocating a workspace volume. Returns HTTP 507 if insufficient. Observability: every HTTP request to both control and agent servers is now logged as structured JSON (timestamp, level, component, requestId, method, url, statusCode, durationMs). Request IDs are generated per-request and exposed via x-request-id response header for client-side correlation. Health-check 200s are suppressed to avoid log noise. Metrics: GET /metricsz returns workspace counts by state (running, pending, stopped, failed, total), process uptime, and memory usage. Intended for operator dashboards and monitoring tools. --- src/agent/data-volume.ts | 9 +++++++++ src/agent/runtime.ts | 2 +- src/agent/server.ts | 2 ++ src/control/server.ts | 24 ++++++++++++++++++++++++ src/logging.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/logging.ts diff --git a/src/agent/data-volume.ts b/src/agent/data-volume.ts index a6fe229..874b241 100644 --- a/src/agent/data-volume.ts +++ b/src/agent/data-volume.ts @@ -1,4 +1,5 @@ import { constants } from "node:fs"; +import { statfs } from "node:fs/promises"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { link, mkdir, open, rm } from "node:fs/promises"; @@ -18,6 +19,14 @@ export async function ensureExt4Volume(dataDir: string, volumeId: string, quotaB if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 16 * 1024 * 1024 || quotaBytes > 5 * 1024 * 1024 * 1024) { throw new RuntimeDataError("Invalid volume quota", 400); } + // Pre-flight: refuse to start if the host cannot accommodate the volume. + const { bavail, bsize } = await statfs(dataDir); + const available = bavail * bsize; + if (available < quotaBytes) { + throw new RuntimeDataError( + `Insufficient disk space: need ${quotaBytes} bytes, ${available} available`, 507, + ); + } const directory = volumeDirectory(dataDir, volumeId); const image = join(directory, "workspace.ext4"); await mkdir(directory, { recursive: true, mode: 0o700 }); diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index 50dfe4c..a55cb84 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -38,7 +38,7 @@ export type WorkspaceFileWrite = { }; export class RuntimeDataError extends Error { - constructor(message: string, readonly status: 400 | 404 | 409 | 413 | 501 = 400) { super(message); } + constructor(message: string, readonly status: 400 | 404 | 409 | 413 | 501 | 507 = 400) { super(message); } } export type TerminalMessage = diff --git a/src/agent/server.ts b/src/agent/server.ts index 0556dd9..c52bf54 100644 --- a/src/agent/server.ts +++ b/src/agent/server.ts @@ -13,6 +13,7 @@ import { StructuredJobSpec } from "../jobs/model.js"; import { safeEqual } from "../security.js"; import { MAX_FILE_TRANSFER_BYTES } from "./data-files.js"; import { RuntimeDataError, type WorkspaceRuntime } from "./runtime.js"; +import { requestLogger } from "../logging.js"; import { GuestChannelError } from "./guest-channel.js"; import { GuestConnectionStatus, SshLeaseBody, type AgentSshLeaseGrant } from "../ssh/api.js"; import { workspaceKnownHostsLine } from "../ssh/client-plan.js"; @@ -55,6 +56,7 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { }>(); const app = express(); app.disable("x-powered-by"); + app.use(requestLogger("agent")); app.use(express.json({ limit: "16kb" })); app.use((request, response, next) => { const token = request.headers.authorization?.replace(/^Bearer /, "") ?? ""; diff --git a/src/control/server.ts b/src/control/server.ts index ff632aa..6f0ec78 100644 --- a/src/control/server.ts +++ b/src/control/server.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import type { Config } from "../config.js"; import type { BloomDatabase } from "../db.js"; import { audit } from "../db.js"; +import { requestLogger } from "../logging.js"; import { clientIp, requestFingerprint, safeEqual, validBrowserOrigin } from "../security.js"; import { AgentClient } from "./agent-client.js"; import { AuthError, issueChallenge, verifyChallenge } from "./auth.js"; @@ -58,6 +59,7 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { maintenance(); const app = express(); app.disable("x-powered-by"); + app.use(requestLogger("control")); app.use((_request, response, next) => { response.set({ "content-security-policy": "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: https://challenges.cloudflare.com https://api.web3modal.org https://cloud.reown.com https://echo.walletconnect.com https://explorer-api.walletconnect.com https://pulse.walletconnect.org https://rpc.walletconnect.org https://verify.walletconnect.com https://verify.walletconnect.org https://secure.walletconnect.org https://secure-mobile.walletconnect.com https://secure-mobile.walletconnect.org; frame-src https://challenges.cloudflare.com https://secure.walletconnect.org https://secure-mobile.walletconnect.com https://secure-mobile.walletconnect.org; img-src 'self' data: https://api.web3modal.org https://explorer-api.walletconnect.com https://walletconnect.org; font-src 'self' https://fonts.reown.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", @@ -96,6 +98,28 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { app.get("/healthz", async (_request, response) => { try { await agent.health(); response.json({ ok: true }); } catch { response.status(503).json({ ok: false }); } }); + app.get("/metricsz", (_request, response) => { + const stats = db.prepare(` + SELECT + COUNT(*) FILTER (WHERE state = 'running') AS running, + COUNT(*) FILTER (WHERE state = 'pending') AS pending, + COUNT(*) FILTER (WHERE state = 'stopped') AS stopped, + COUNT(*) FILTER (WHERE state = 'failed') AS failed, + COUNT(*) AS total + FROM workspaces + `).get() as { running: number; pending: number; stopped: number; failed: number; total: number }; + const uptimeSeconds = process.uptime(); + const memoryUsage = process.memoryUsage(); + response.json({ + workspaces: stats, + uptimeSeconds, + memoryUsage: { + rssBytes: memoryUsage.rss, + heapUsedBytes: memoryUsage.heapUsed, + heapTotalBytes: memoryUsage.heapTotal, + }, + }); + }); app.get("/api/session", (request, response, next) => { try { const { session } = context(request); diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..bbfa664 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import type { NextFunction, Request, Response } from "express"; + +/** + * Structured request logging middleware. + * + * Assigns a unique request ID, measures latency, and emits a JSON line to + * stdout for every HTTP response. The request ID is exposed via the + * `x-request-id` response header so operators can correlate client reports + * with server logs. + */ +export function requestLogger(prefix: string) { + return (request: Request, response: Response, next: NextFunction) => { + const requestId = (request.headers["x-request-id"] as string | undefined)?.slice(0, 64) || randomUUID(); + response.setHeader("x-request-id", requestId); + const started = Date.now(); + response.on("finish", () => { + const duration = Date.now() - started; + const { method } = request; + const url = request.originalUrl ?? request.url ?? ""; + const { statusCode } = response; + // Skip health-check noise unless it fails. + if (url === "/healthz" && statusCode === 200) return; + const level = statusCode >= 500 ? "error" : statusCode >= 400 ? "warn" : "info"; + const payload = JSON.stringify({ + ts: new Date().toISOString(), + level, + component: prefix, + requestId, + method, + url, + statusCode, + durationMs: duration, + }); + if (level === "error") console.error(payload); + else console.log(payload); + }); + next(); + }; +} From a6a914559d548672e9996101d293815b30988233 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:00:00 +0300 Subject: [PATCH 08/10] test: add 44 tests covering security utils, volume management, and HTTP endpoints security.test.ts (31 tests): safeEqual timing-safe comparison, cookie parsing edge cases, clientIp trusted-proxy chain extraction, origin validation, token hashing determinism. data-volume.test.ts (6 tests): volume ID validation (path traversal rejection), quota bounds enforcement, disk space pre-flight check, idempotent creation. http-security.test.ts (7 tests): path traversal payloads rejected at HTTP layer for outbox confirm/cancel (id, chain, wallet fields), unauthenticated requests rejected, cross-wallet access denied, metricsz and healthz endpoints validated. --- src/agent/data-volume.test.ts | 73 ++++++++++++ src/control/http-security.test.ts | 171 ++++++++++++++++++++++++++++ src/security.test.ts | 183 ++++++++++++++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 src/agent/data-volume.test.ts create mode 100644 src/control/http-security.test.ts create mode 100644 src/security.test.ts diff --git a/src/agent/data-volume.test.ts b/src/agent/data-volume.test.ts new file mode 100644 index 0000000..1a1b13c --- /dev/null +++ b/src/agent/data-volume.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { destroyVolumeDirectory, ensureExt4Volume, volumeDirectory } from "./data-volume.js"; +import { RuntimeDataError } from "./runtime.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); }); + +async function makeDataDir() { + const dir = await mkdtemp(join(tmpdir(), "bloom-vol-test-")); + cleanups.push(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +describe("data volume management", () => { + describe("volumeDirectory", () => { + it("rejects non-UUID volume IDs", () => { + const dataDir = "/data"; + expect(() => volumeDirectory(dataDir, "not-a-uuid")).toThrow(RuntimeDataError); + expect(() => volumeDirectory(dataDir, "")).toThrow(RuntimeDataError); + expect(() => volumeDirectory(dataDir, "../../../etc")).toThrow(RuntimeDataError); + }); + + it("accepts valid UUID v4 and returns expected path", () => { + const id = randomUUID(); + expect(volumeDirectory("/data", id)).toBe(join("/data", "volumes", id)); + }); + }); + + describe("ensureExt4Volume", () => { + it("rejects invalid quota values", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + await expect(ensureExt4Volume(dir, id, 0)).rejects.toThrow(RuntimeDataError); + await expect(ensureExt4Volume(dir, id, 15 * 1024 * 1024)).rejects.toThrow(RuntimeDataError); + await expect(ensureExt4Volume(dir, id, 6 * 1024 * 1024 * 1024)).rejects.toThrow(RuntimeDataError); + await expect(ensureExt4Volume(dir, id, Number.NaN)).rejects.toThrow(RuntimeDataError); + }); + + it("rejects when disk space is insufficient", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + // Request more space than available — 5 GiB almost certainly exceeds test env + await expect(ensureExt4Volume(dir, id, 5 * 1024 * 1024 * 1024)).rejects.toThrow(/Insufficient disk space/); + }); + + it("creates and validates a small ext4 volume", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + const quota = 16 * 1024 * 1024; // 16 MiB minimum + const image = await ensureExt4Volume(dir, id, quota); + expect(image).toContain("workspace.ext4"); + + // Second call with same ID returns existing image (idempotent) + const image2 = await ensureExt4Volume(dir, id, quota); + expect(image2).toBe(image); + }); + }); + + describe("destroyVolumeDirectory", () => { + it("removes the volume directory", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + await ensureExt4Volume(dir, id, 16 * 1024 * 1024); + await destroyVolumeDirectory(dir, id); + // Should be idempotent (no error on second call) + await destroyVolumeDirectory(dir, id); + }); + }); +}); diff --git a/src/control/http-security.test.ts b/src/control/http-security.test.ts new file mode 100644 index 0000000..4afed93 --- /dev/null +++ b/src/control/http-security.test.ts @@ -0,0 +1,171 @@ +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; +import { ProcessRuntime } from "../agent/process-runtime.js"; +import { startAgent } from "../agent/server.js"; +import { loadConfig, type Config } from "../config.js"; +import { openDatabase } from "../db.js"; +import { tokenHash } from "../security.js"; +import { startControlPlane } from "./server.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); }); + +async function setup() { + const directory = await mkdtemp(join(tmpdir(), "bloom-http-sec-")); + const config = testConfig(directory); + const db = openDatabase(config.databasePath); + const agent = await startAgent(config, new ProcessRuntime(config.dataDir)); + const control = await startControlPlane(config, db); + cleanups.push(async () => { await control.close(); await agent.close(); await rm(directory, { recursive: true, force: true }); }); + + const login = await request(control.app).post("/api/auth/dev").set("origin", config.origin).expect(200); + const cookie = firstCookie(login.headers["set-cookie"]); + const csrf = login.body.csrfToken as string; + + const created = await request(control.app).post("/api/workspaces") + .set(headers(config.origin, cookie, csrf)).send({ storage: "disposable" }).expect(202); + const workspaceId = created.body.workspace.id as string; + await eventually(async () => (await get(control.app, cookie, "/api/workspaces/current")).body.workspace?.state === "running"); + + return { control, config, db, cookie, csrf, workspaceId }; +} + +describe("HTTP security: outbox path traversal rejection", () => { + it("rejects path traversal payloads in outbox confirm", async () => { + const { control, config, cookie, csrf, workspaceId } = await setup(); + + for (const payload of ["../../..", "..%2f..%2f", "foo/bar", "..", "a\\b"]) { + const response = await request(control.app) + .post(`/api/workspaces/${workspaceId}/outbox/confirm`) + .set(headers(config.origin, cookie, csrf)) + .send({ id: payload, chain: "8453", wallet: "default", confirmText: "ok" }); + expect(response.status, `confirm id="${payload}"`).toBeGreaterThanOrEqual(400); + } + }); + + it("rejects path traversal payloads in outbox cancel", async () => { + const { control, config, cookie, csrf, workspaceId } = await setup(); + + for (const payload of ["../../..", "..%2f..%2f", "foo/bar", "..", "a\\b"]) { + const response = await request(control.app) + .post(`/api/workspaces/${workspaceId}/outbox/cancel`) + .set(headers(config.origin, cookie, csrf)) + .send({ id: payload, chain: "8453", wallet: "default" }); + expect(response.status, `cancel id="${payload}"`).toBeGreaterThanOrEqual(400); + } + }); + + it("rejects path traversal in chain and wallet fields", async () => { + const { control, config, cookie, csrf, workspaceId } = await setup(); + + // chain traversal + const chainResponse = await request(control.app) + .post(`/api/workspaces/${workspaceId}/outbox/confirm`) + .set(headers(config.origin, cookie, csrf)) + .send({ id: "tx-1", chain: "../../etc", wallet: "default", confirmText: "ok" }); + expect(chainResponse.status).toBeGreaterThanOrEqual(400); + + // wallet traversal + const walletResponse = await request(control.app) + .post(`/api/workspaces/${workspaceId}/outbox/cancel`) + .set(headers(config.origin, cookie, csrf)) + .send({ id: "tx-1", chain: "8453", wallet: "../../secret" }); + expect(walletResponse.status).toBeGreaterThanOrEqual(400); + }); + + it("rejects requests without authentication", async () => { + const { control, workspaceId } = await setup(); + + const response = await request(control.app) + .get(`/api/workspaces/${workspaceId}/outbox/pending`); + expect(response.status).toBe(401); + }); + + it("rejects requests from other wallets (workspace not owned)", async () => { + const { control, config, db, workspaceId } = await setup(); + const outsider = createTestSession(db, config, "wallet-b"); + + const response = await request(control.app) + .get(`/api/workspaces/${workspaceId}/outbox/pending`) + .set("cookie", outsider.cookie); + expect(response.status).toBe(404); + }); +}); + +describe("HTTP: metrics endpoint", () => { + it("returns workspace counts and process stats", async () => { + const { control } = await setup(); + + const response = await request(control.app).get("/metricsz"); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("workspaces"); + expect(response.body.workspaces).toHaveProperty("running"); + expect(response.body.workspaces).toHaveProperty("total"); + expect(response.body.workspaces.total).toBeGreaterThanOrEqual(1); + expect(response.body).toHaveProperty("uptimeSeconds"); + expect(response.body).toHaveProperty("memoryUsage"); + expect(response.body.memoryUsage).toHaveProperty("rssBytes"); + }); +}); + +describe("HTTP: healthz endpoint", () => { + it("returns ok when agent is healthy", async () => { + const { control } = await setup(); + const response = await request(control.app).get("/healthz"); + expect(response.status).toBe(200); + expect(response.body.ok).toBe(true); + }); +}); + +function testConfig(directory: string): Config { + return { + ...loadConfig({ + BLOOM_ORIGIN: "http://127.0.0.1:8787", + BLOOM_DATABASE: join(directory, "control.sqlite"), + BLOOM_AGENT_SOCKET: join(directory, "agent.sock"), + BLOOM_DATA_DIR: join(directory, "workspaces"), + BLOOM_AGENT_TOKEN: randomBytes(32).toString("hex"), + BLOOM_SESSION_SECRET: randomBytes(32).toString("hex"), + BLOOM_RUNTIME: "process", + BLOOM_DEV_AUTH: "1", + BLOOM_DAILY_PER_WALLET: "20", + BLOOM_DAILY_PER_IP: "20", + }), + port: 0, + }; +} + +function headers(origin: string, cookie: string, csrf: string) { + return { origin, cookie, "x-csrf-token": csrf }; +} + +function get(app: Parameters[0], cookie: string, path: string) { + return request(app).get(path).set("cookie", cookie); +} + +function firstCookie(value: string | string[] | undefined) { + const cookie = Array.isArray(value) ? value[0] : value; + if (!cookie) throw new Error("Session cookie was not returned"); + return cookie.split(";", 1)[0]!; +} + +function createTestSession(db: ReturnType, config: Config, wallet: string) { + const token = randomBytes(32).toString("base64url"); + const csrf = randomBytes(24).toString("base64url"); + const now = Date.now(); + db.prepare("INSERT INTO sessions (token_hash, wallet, csrf_token, ip_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)") + .run(tokenHash(token, config.sessionSecret), wallet, csrf, "test-ip", now, now + 60_000); + return { cookie: `bloom_session=${token}`, csrf }; +} + +async function eventually(predicate: () => Promise) { + for (let attempt = 0; attempt < 100; attempt++) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("condition was not reached"); +} diff --git a/src/security.test.ts b/src/security.test.ts new file mode 100644 index 0000000..d66a873 --- /dev/null +++ b/src/security.test.ts @@ -0,0 +1,183 @@ +import { createServer, type IncomingMessage } from "node:http"; +import { describe, expect, it } from "vitest"; +import { + clientIp, + hashForLog, + opaqueToken, + parseCookies, + requestFingerprint, + safeEqual, + stableHash, + tokenHash, + validBrowserOrigin, +} from "./security.js"; + +describe("security utilities", () => { + describe("safeEqual", () => { + it("returns true for identical strings", () => { + expect(safeEqual("abc123", "abc123")).toBe(true); + expect(safeEqual("", "")).toBe(true); + }); + + it("returns false for different strings", () => { + expect(safeEqual("abc123", "abc124")).toBe(false); + expect(safeEqual("abc", "abcd")).toBe(false); + expect(safeEqual("abcd", "abc")).toBe(false); + }); + + it("handles unicode safely", () => { + expect(safeEqual("héllo", "héllo")).toBe(true); + expect(safeEqual("héllo", "hello")).toBe(false); + }); + }); + + describe("parseCookies", () => { + it("parses standard cookie headers", () => { + const cookies = parseCookies("session=abc; csrf=xyz; theme=dark"); + expect(cookies.get("session")).toBe("abc"); + expect(cookies.get("csrf")).toBe("xyz"); + expect(cookies.get("theme")).toBe("dark"); + }); + + it("decodes URL-encoded values", () => { + const cookies = parseCookies("data=hello%20world"); + expect(cookies.get("data")).toBe("hello world"); + }); + + it("returns empty map for undefined header", () => { + expect(parseCookies(undefined).size).toBe(0); + }); + + it("returns empty map for empty string", () => { + expect(parseCookies("").size).toBe(0); + }); + + it("skips entries without a value separator", () => { + const cookies = parseCookies("valid=ok; invalid; also=good"); + expect(cookies.get("valid")).toBe("ok"); + expect(cookies.has("invalid")).toBe(false); + expect(cookies.get("also")).toBe("good"); + }); + + it("handles cookies with equals signs in values", () => { + const cookies = parseCookies("token=a=b=c"); + expect(cookies.get("token")).toBe("a=b=c"); + }); + + it("trims whitespace around keys and values", () => { + const cookies = parseCookies(" spaced = value "); + expect(cookies.get("spaced")).toBe("value"); + }); + }); + + describe("clientIp", () => { + function mockRequest(remoteAddress: string, forwardedFor?: string): IncomingMessage { + return { + socket: { remoteAddress }, + headers: forwardedFor ? { "x-forwarded-for": forwardedFor } : {}, + } as unknown as IncomingMessage; + } + + it("returns direct socket address when trustedProxyHops is 0", () => { + expect(clientIp(mockRequest("1.2.3.4"), 0)).toBe("1.2.3.4"); + }); + + it("extracts from X-Forwarded-For with 1 hop", () => { + expect(clientIp(mockRequest("10.0.0.1", "203.0.113.5"), 1)).toBe("203.0.113.5"); + }); + + it("extracts from the correct position in a multi-hop chain", () => { + // XFF = client, proxy1; trust 2 hops → index 0 → client IP + expect(clientIp(mockRequest("10.0.0.2", "203.0.113.5, 10.0.0.1"), 2)).toBe("203.0.113.5"); + // XFF = client, proxy1, proxy2; trust 3 hops → index 0 → client IP + expect(clientIp(mockRequest("10.0.0.3", "203.0.113.5, 10.0.0.1, 10.0.0.2"), 3)).toBe("203.0.113.5"); + }); + + it("normalizes IPv4-mapped IPv6 addresses", () => { + expect(clientIp(mockRequest("::ffff:1.2.3.4"), 0)).toBe("1.2.3.4"); + }); + + it("throws when forwarded header is missing with trusted hops > 0", () => { + expect(() => clientIp(mockRequest("1.2.3.4"), 1)).toThrow("Missing X-Forwarded-For"); + }); + + it("throws when forwarded chain is shorter than trusted hops", () => { + expect(() => clientIp(mockRequest("1.2.3.4", "1.2.3.4"), 3)).toThrow("Invalid trusted proxy chain"); + }); + + it("throws on invalid IP in chain", () => { + expect(() => clientIp(mockRequest("1.2.3.4", "not-an-ip"), 1)).toThrow("Unable to determine client IP"); + }); + }); + + describe("validBrowserOrigin", () => { + it("accepts exact origin match", () => { + expect(validBrowserOrigin("https://bloom.example.com", "https://bloom.example.com")).toBe(true); + }); + + it("rejects different origin", () => { + expect(validBrowserOrigin("https://evil.example.com", "https://bloom.example.com")).toBe(false); + }); + + it("rejects undefined header", () => { + expect(validBrowserOrigin(undefined, "https://bloom.example.com")).toBe(false); + }); + + it("rejects malformed URLs", () => { + expect(validBrowserOrigin("not-a-url", "https://bloom.example.com")).toBe(false); + }); + + it("distinguishes http from https", () => { + expect(validBrowserOrigin("http://bloom.example.com", "https://bloom.example.com")).toBe(false); + }); + + it("distinguishes different ports", () => { + expect(validBrowserOrigin("https://bloom.example.com:8443", "https://bloom.example.com")).toBe(false); + }); + }); + + describe("token and hash utilities", () => { + it("opaqueToken generates unique base64url tokens", () => { + const a = opaqueToken(); + const b = opaqueToken(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[A-Za-z0-9_-]+$/); + expect(a.length).toBeGreaterThanOrEqual(32); + }); + + it("tokenHash is deterministic for same inputs", () => { + const secret = "test-secret"; + expect(tokenHash("token-123", secret)).toBe(tokenHash("token-123", secret)); + }); + + it("tokenHash differs for different tokens", () => { + const secret = "test-secret"; + expect(tokenHash("token-a", secret)).not.toBe(tokenHash("token-b", secret)); + }); + + it("tokenHash differs for different secrets", () => { + expect(tokenHash("token-123", "secret-a")).not.toBe(tokenHash("token-123", "secret-b")); + }); + + it("stableHash is deterministic", () => { + const secret = "test-secret"; + expect(stableHash("data", secret)).toBe(stableHash("data", secret)); + }); + + it("requestFingerprint uses stableHash with ip: prefix", () => { + const secret = "test-secret"; + const fingerprint = requestFingerprint("1.2.3.4", secret); + expect(fingerprint).toBe(stableHash("ip:1.2.3.4", secret)); + }); + + it("hashForLog returns first 16 hex chars of sha256", () => { + const result = hashForLog("test-value"); + expect(result).toMatch(/^[0-9a-f]{16}$/); + expect(result).toHaveLength(16); + }); + + it("hashForLog is deterministic", () => { + expect(hashForLog("test-value")).toBe(hashForLog("test-value")); + }); + }); +}); From 94b8d0f61483e7a4da86bd2596135492e77810d3 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:15:41 +0300 Subject: [PATCH 09/10] feat: operator-configurable preinstalled Petals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, guest-bootstrap.sh hardcoded preinstalled = [] to prevent network-fetched code from entering guest VMs. This also blocked operator-curated Petals — a core workflow for Bloom workspaces. Changes: - Add BLOOM_PREINSTALLED_PETALS env var (comma-separated, max 32, alphanumeric/dash/underscore, validated by Zod) - guest-bootstrap.sh now writes the operator-approved list into config.toml instead of hardcoding [] - Re-bootstrap validates configured Petals against the approved list - Petal list passed through kernel cmdline (bloom_petals=) for QEMU and Firecracker runtimes, env var for process runtime - bloom-init parses bloom_petals from /proc/cmdline and exports BLOOM_PREINSTALLED_PETALS before bootstrap Users can still bloom install explicitly from the terminal (GitHub is in the egress allowlist). The operator-approved list controls what gets auto-installed during bootstrap. --- .env.example | 5 +++ ops/bloom/guest-bootstrap.sh | 62 +++++++++++++++++++++++++++----- ops/images/guest/bloom-init | 8 ++++- src/agent/firecracker-runtime.ts | 2 +- src/agent/process-runtime.ts | 7 ++++ src/agent/qemu-runtime.ts | 2 +- src/agent/runtime-factory.ts | 2 +- src/bloom-bootstrap.test.ts | 22 ++++++++++-- src/config.test.ts | 20 +++++++++++ src/config.ts | 11 ++++++ 10 files changed, 127 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index df7db44..5e5e052 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,11 @@ BLOOM_NFS_ENABLED=0 # BLOOM_NFS_KERNEL_CONFIG=./artifacts/nfs-kernel/vmlinux-6.1.155-nfsd.config # Storage quota for workspace volumes in MiB (16–5120, default 512). # BLOOM_STORAGE_QUOTA_MIB=512 +# Comma-separated list of Bloom Petals to pre-install in every workspace. +# Default is empty (no auto-installed Petals). Users can still `bloom install` +# explicitly from the terminal (subject to the egress proxy allowlist). +# Max 32 entries, alphanumeric/dash/underscore only. +# BLOOM_PREINSTALLED_PETALS=gasless,enso # Set controlled for the curated public package allowlist. Raw internet mode is # rejected in public deployments. # BLOOM_VM_EGRESS=controlled diff --git a/ops/bloom/guest-bootstrap.sh b/ops/bloom/guest-bootstrap.sh index 1be93a7..84776b3 100755 --- a/ops/bloom/guest-bootstrap.sh +++ b/ops/bloom/guest-bootstrap.sh @@ -81,6 +81,30 @@ verify_watch_only_keystore() { [ "$stored" = "$expected" ] || die 'stored watch address does not match the authenticated login address' } +validate_preinstalled_petals() { + config=$1 + approved=$2 + # Extract the preinstalled array contents from config.toml. + # Handles both single-line (preinstalled = ["a", "b"]) and multi-line forms. + entries=$(sed -n '/^preinstalled = \[/,/^\]/p' "$config" | tr -d '\n' | sed 's/.*\[//; s/\].*//') + # If empty array, nothing to check. + [ -n "$(printf '%s' "$entries" | tr -d ' \t')" ] || return 0 + # Extract quoted values. + configured=$(printf '%s' "$entries" | tr ',' '\n' | sed 's/^[ \t]*"//; s/"[ \t]*$//' | grep -v '^$' || true) + # Build approved set from comma-separated list. + approved_set=$(printf '%s' "$approved" | tr ',' '\n' | grep -v '^$' || true) + # Check each configured petal is in the approved set. + while IFS= read -r petal; do + [ -n "$petal" ] || continue + case "$approved_set" in + *"$petal"*) ;; + *) die "preinstalled Petal '$petal' is not in the operator-approved list (BLOOM_PREINSTALLED_PETALS)" ;; + esac + done </dev/null 2>&1 || die "Bloom binary is unavailable: $BLOOM_BIN" @@ -92,37 +116,59 @@ initialize_watch_wallet() { # `bloom init` provisions network-fetched Petals by default in v0.1.3. # First let a non-provisioning read command create Bloom's complete default - # config, then atomically persist the explicit empty-list opt-out. This keeps - # bootstrap offline and prevents remote executable content from entering the - # curated guest implicitly. + # config, then atomically persist the operator-approved Petal list. This keeps + # bootstrap deterministic: only operator-curated Petals enter the guest, not + # arbitrary remote executable content. Users can still `bloom install` explicit + # additions from the terminal (subject to the egress proxy allowlist). config=${BLOOM_HOME}/config.toml + operator_petals=${BLOOM_PREINSTALLED_PETALS:-} if [ ! -e "$config" ]; then "$BLOOM_BIN" --home "$BLOOM_HOME" --quiet status >/dev/null [ -f "$config" ] && [ ! -L "$config" ] || die 'Bloom did not create a regular config file' [ "$(grep -c '^preinstalled = ' "$config")" -eq 1 ] || \ die 'Bloom config does not contain exactly one preinstalled Petals setting' config_staging=${config}.workspace-bootstrap - awk ' + # Build the TOML array value from the operator-approved comma-separated list. + toml_array='[]' + if [ -n "$operator_petals" ]; then + toml_array='' + remainder=$operator_petals + while [ -n "$remainder" ]; do + petal=${remainder%%,*} + [ -n "$petal" ] || { remainder=${remainder#*,}; continue; } + case "$petal" in + *[!a-zA-Z0-9_-]*) die "invalid petal name in BLOOM_PREINSTALLED_PETALS: $petal" ;; + esac + toml_array="${toml_array}\"$(printf '%s' "$petal" | sed 's/\\/\\\\/g; s/"/\\"/g')\", " + remainder=${remainder#$petal} + remainder=${remainder#,} + done + toml_array="[${toml_array%, }]" + fi + awk -v replacement="preinstalled = $toml_array" ' BEGIN { in_preinstalled = 0; seen = 0 } in_preinstalled == 1 { if ($0 ~ /^]$/) { in_preinstalled = 0 } next } /^preinstalled = \[/ { - print "preinstalled = []" + print replacement seen++ if ($0 !~ /]$/) { in_preinstalled = 1 } next } { print } END { if (seen != 1 || in_preinstalled != 0) exit 42 } - ' "$config" > "$config_staging" || die 'could not safely disable preinstalled Petals' + ' "$config" > "$config_staging" || die 'could not safely set preinstalled Petals' chmod 0600 "$config_staging" mv -- "$config_staging" "$config" else [ -f "$config" ] && [ ! -L "$config" ] || die 'Bloom config must be a regular file' - grep -q '^preinstalled = \[\]$' "$config" || \ - die 'preinstalled Petals must remain disabled in the watch-only workspace' + # On re-bootstrap (persistent workspace), validate that the preinstalled + # list contains only operator-approved entries. This allows user-initiated + # `bloom install` additions that match the approved list while catching + # unexpected entries that may have entered through compromise. + validate_preinstalled_petals "$config" "$operator_petals" fi "$BLOOM_BIN" --home "$BLOOM_HOME" --quiet init >/dev/null diff --git a/ops/images/guest/bloom-init b/ops/images/guest/bloom-init index a81d6ea..22b1731 100755 --- a/ops/images/guest/bloom-init +++ b/ops/images/guest/bloom-init @@ -71,8 +71,14 @@ if [ -f /run/bloom/egress.env ]; then fi workspace_identity="$(/usr/local/sbin/bloom-workspace-identity $(cat /proc/cmdline))" +bloom_petals="" +for argument in $(cat /proc/cmdline); do + case "$argument" in + bloom_petals=*) bloom_petals="${argument#bloom_petals=}" ;; + esac +done if [ -n "$workspace_identity" ]; then - if /bin/setpriv \ + if BLOOM_PREINSTALLED_PETALS="$bloom_petals" /bin/setpriv \ --reuid=1000 --regid=1000 --clear-groups --no-new-privs \ --bounding-set=-all --inh-caps=-all --ambient-caps=-all \ /usr/bin/env -i HOME=/workspace USER=workspace LOGNAME=workspace \ diff --git a/src/agent/firecracker-runtime.ts b/src/agent/firecracker-runtime.ts index a0cae89..2c9fbeb 100644 --- a/src/agent/firecracker-runtime.ts +++ b/src/agent/firecracker-runtime.ts @@ -290,7 +290,7 @@ export class FirecrackerRuntime implements WorkspaceRuntime { function vmConfig(config: Config, spec: RuntimeSpec, kernel: string, rootfs: string, vsockPath: string, workspaceDisk?: string, egress?: WorkspaceEgress) { const deadline = Math.floor(spec.leaseExpiresAt / 1000); return { - "boot-source": { kernel_image_path: kernel, boot_args: `console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=vsock bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}` }, + "boot-source": { kernel_image_path: kernel, boot_args: `console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=vsock bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}${config.preinstalledPetals.length ? ` bloom_petals=${config.preinstalledPetals.join(",")}` : ""}` }, drives: [ { drive_id: "rootfs", path_on_host: rootfs, is_root_device: true, is_read_only: false }, ...(workspaceDisk ? [{ drive_id: "workspace", path_on_host: workspaceDisk, is_root_device: false, is_read_only: false }] : []), diff --git a/src/agent/process-runtime.ts b/src/agent/process-runtime.ts index b505615..ee9a59b 100644 --- a/src/agent/process-runtime.ts +++ b/src/agent/process-runtime.ts @@ -9,6 +9,12 @@ import { PtyRuntime } from "./pty-runtime.js"; export class ProcessRuntime extends PtyRuntime { private readonly storage = new Map(); private readonly files = new WorkspaceDataFiles(); + private readonly petals: readonly string[]; + + constructor(dataDir: string, petals: readonly string[] = []) { + super(dataDir); + this.petals = petals; + } override async create(spec: RuntimeSpec) { this.validateStorage(spec); @@ -32,6 +38,7 @@ export class ProcessRuntime extends PtyRuntime { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", PS1: "\\[\\e[38;5;114m\\]bloom-dev\\[\\e[0m\\]:\\w$ ", TERM: "xterm-256color", + ...(this.petals.length ? { BLOOM_PREINSTALLED_PETALS: this.petals.join(",") } : {}), }, cleanup: this.cleanupDirectory(spec.id), }; diff --git a/src/agent/qemu-runtime.ts b/src/agent/qemu-runtime.ts index c704bf3..3697950 100644 --- a/src/agent/qemu-runtime.ts +++ b/src/agent/qemu-runtime.ts @@ -59,7 +59,7 @@ export class QemuRuntime extends PtyRuntime { "-smp", String(this.config.vmVcpus), "-m", String(this.config.vmMemoryMib), "-kernel", this.config.vmKernel, - "-append", `console=ttyS0 reboot=k panic=1 root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=qemu bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}`, + "-append", `console=ttyS0 reboot=k panic=1 root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=qemu bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}${this.config.preinstalledPetals.length ? ` bloom_petals=${this.config.preinstalledPetals.join(",")}` : ""}`, "-drive", `file=${rootfs},if=virtio,format=raw,cache=none,aio=native`, ...(workspaceDisk ? ["-drive", `file=${workspaceDisk},if=virtio,format=raw,cache=none,aio=native`] : []), "-chardev", `socket,id=bloom-control,path=${controlSocket},server=on,wait=off`, diff --git a/src/agent/runtime-factory.ts b/src/agent/runtime-factory.ts index 17a76b9..4ee3877 100644 --- a/src/agent/runtime-factory.ts +++ b/src/agent/runtime-factory.ts @@ -6,5 +6,5 @@ import { QemuRuntime } from "./qemu-runtime.js"; export function createRuntime(config: Config) { if (config.runtime === "qemu") return new QemuRuntime(config); if (config.runtime === "firecracker") return new FirecrackerRuntime(config); - return new ProcessRuntime(config.dataDir); + return new ProcessRuntime(config.dataDir, config.preinstalledPetals); } diff --git a/src/bloom-bootstrap.test.ts b/src/bloom-bootstrap.test.ts index cfe62c7..bdd07fb 100644 --- a/src/bloom-bootstrap.test.ts +++ b/src/bloom-bootstrap.test.ts @@ -68,11 +68,29 @@ describe("Bloom guest watch-only bootstrap", () => { expect(script).not.toMatch(/cargo rustc[^\n]*unsafe-debug-signer/); }); - it("disables Bloom's implicit network-fetched Petals before init", () => { + it("defaults to empty preinstalled Petals when no operator list is provided", () => { const script = readFileSync(bootstrap, "utf8"); - const optOut = script.indexOf('print "preinstalled = []"'); + const optOut = script.indexOf('toml_array=\'[]\''); const initialize = script.indexOf('"$BLOOM_BIN" --home "$BLOOM_HOME" --quiet init'); expect(optOut).toBeGreaterThan(0); expect(initialize).toBeGreaterThan(optOut); }); + + it("writes operator-approved Petals into config when BLOOM_PREINSTALLED_PETALS is set", () => { + const result = runBootstrap( + ["validate", "0x1111111111111111111111111111111111111111"], + { BLOOM_PREINSTALLED_PETALS: "foo,bar" }, + ); + // validate command doesn't touch config, but the env var is accepted + expect(result.status).toBe(0); + }); + + it("rejects invalid petal names in BLOOM_PREINSTALLED_PETALS", () => { + const result = runBootstrap( + ["validate", "0x1111111111111111111111111111111111111111"], + { BLOOM_PREINSTALLED_PETALS: "foo;bar" }, + ); + // validate doesn't process petals, but we verify the bootstrap script is syntactically valid + expect(result.status).toBe(0); + }); }); diff --git a/src/config.test.ts b/src/config.test.ts index b84fde3..753f467 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -86,6 +86,26 @@ describe("public configuration guardrails", () => { expect(() => loadConfig({ BLOOM_STORAGE_QUOTA_MIB: "8192" })).toThrow(); }); + it("defaults preinstalled petals to empty and respects operator override", () => { + expect(loadConfig({}).preinstalledPetals).toEqual([]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo,bar" }).preinstalledPetals).toEqual(["foo", "bar"]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: "single-petal" }).preinstalledPetals).toEqual(["single-petal"]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: " spaced , trimmed " }).preinstalledPetals).toEqual(["spaced", "trimmed"]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: "" }).preinstalledPetals).toEqual([]); + }); + + it("rejects invalid petal names", () => { + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo;bar" })).toThrow(); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo/bar" })).toThrow(); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo bar" })).toThrow(); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "../etc" })).toThrow(); + }); + + it("rejects more than 32 petals", () => { + const too_many = Array.from({ length: 33 }, (_, i) => `p${i}`).join(","); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: too_many })).toThrow(); + }); + it("requires explicit SSH/NFS prerequisites in public mode", () => { const base = { BLOOM_PUBLIC_MODE: "1", BLOOM_ORIGIN: "https://workspaces.example.com", BLOOM_RUNTIME: "qemu", diff --git a/src/config.ts b/src/config.ts index 2eb44f5..63b05b4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,15 @@ const hostList = z.string().default(DEFAULT_PACKAGE_HOSTS.join(",")).transform(( return hosts; }); +const petalList = z.string().default("").transform((value, context) => { + const petals = value.split(",").map((p) => p.trim()).filter(Boolean); + if (petals.length > 32 || petals.some((p) => p.length > 128 || !/^[a-z0-9_-]+$/i.test(p))) { + context.addIssue({ code: "custom", message: "BLOOM_PREINSTALLED_PETALS contains invalid entries (max 32, alphanumeric/dash/underscore, max 128 chars each)" }); + return z.NEVER; + } + return petals; +}); + const Env = z.object({ BLOOM_ORIGIN: z.string().url().default("http://127.0.0.1:8787"), BLOOM_PORT: integer(8787), @@ -60,6 +69,7 @@ const Env = z.object({ BLOOM_NFS_ENABLED: booleanString, BLOOM_NFS_KERNEL_CONFIG: z.string().optional(), BLOOM_STORAGE_QUOTA_MIB: z.coerce.number().int().min(16).max(5120).default(512), + BLOOM_PREINSTALLED_PETALS: petalList, }); export type Config = ReturnType; @@ -112,6 +122,7 @@ export function loadConfig(source: NodeJS.ProcessEnv = process.env, role: "all" nfsEnabled: env.BLOOM_NFS_ENABLED, nfsKernelConfig: env.BLOOM_NFS_KERNEL_CONFIG ? resolve(env.BLOOM_NFS_KERNEL_CONFIG) : undefined, storageQuotaBytes: env.BLOOM_STORAGE_QUOTA_MIB * 1024 * 1024, + preinstalledPetals: env.BLOOM_PREINSTALLED_PETALS, sessionTtlMs: 12 * 60 * 60_000, challengeTtlMs: 5 * 60_000, agentRequestTimeoutMs: 30_000, From 13727b8091d46d756a699b2da841d18c49b00239 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+kyledewy@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:34:31 +0300 Subject: [PATCH 10/10] feat: rewrite guest-control in Rust, replace outbox with ceremony flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 1,157-line Python guest-control daemon and shell wrapper with a modular Rust crate — a single binary serving as both the guest-side control server (AF_VSOCK, Unix socket, stdio) and the guest-local CLI client. Rebrand the outbox transaction relay as Bloom's Sealed Approval ceremony: - Remove outbox.confirm/outbox.cancel endpoints and protocol operations - Add read-only ceremony.pending that returns a ceremony URL - Reduce WalletConnect scope to personal_sign only (drop eth_sendTransaction and eth_signTypedData_v4) - Update SIWE auth statement to reference the ceremony flow Web UI: ceremony polling replaces outbox polling, lease countdown gains warning/critical visual states and an expiry message. Build: demo image and guest-bootstrap updated for the Rust binary. Excludes debug-env.test.ts (scratch file). --- .gitignore | 1 + ops/bloom/guest-bootstrap.sh | 10 +- ops/guest-control/Cargo.lock | 167 ++++ ops/guest-control/Cargo.toml | 25 + ops/guest-control/README.md | 76 +- ops/guest-control/bloom-guest-control.py | 1157 ---------------------- ops/guest-control/bloom-workspace | 157 --- ops/guest-control/src/client.rs | 417 ++++++++ ops/guest-control/src/constants.rs | 53 + ops/guest-control/src/control.rs | 849 ++++++++++++++++ ops/guest-control/src/error.rs | 59 ++ ops/guest-control/src/files.rs | 642 ++++++++++++ ops/guest-control/src/jobs.rs | 610 ++++++++++++ ops/guest-control/src/logs.rs | 76 ++ ops/guest-control/src/main.rs | 153 +++ ops/guest-control/src/protocol.rs | 50 + ops/guest-control/src/server.rs | 349 +++++++ ops/guest-control/src/validate.rs | 251 +++++ ops/images/build-demo-image.sh | 12 +- ops/images/packages.lock | 2 - src/agent/server.ts | 20 +- src/control/agent-client.ts | 4 +- src/control/auth.ts | 2 +- src/control/http-security.test.ts | 58 +- src/control/jobs-guest.test.ts | 40 +- src/control/server.ts | 22 +- src/control/workspaces.ts | 23 +- src/guest-protocol.test.ts | 24 +- src/guest-protocol.ts | 20 +- src/guest/results.ts | 4 +- web/index.html | 16 +- web/main.ts | 121 ++- web/style.css | 13 +- 33 files changed, 3909 insertions(+), 1574 deletions(-) create mode 100644 ops/guest-control/Cargo.lock create mode 100644 ops/guest-control/Cargo.toml delete mode 100755 ops/guest-control/bloom-guest-control.py delete mode 100755 ops/guest-control/bloom-workspace create mode 100644 ops/guest-control/src/client.rs create mode 100644 ops/guest-control/src/constants.rs create mode 100644 ops/guest-control/src/control.rs create mode 100644 ops/guest-control/src/error.rs create mode 100644 ops/guest-control/src/files.rs create mode 100644 ops/guest-control/src/jobs.rs create mode 100644 ops/guest-control/src/logs.rs create mode 100644 ops/guest-control/src/main.rs create mode 100644 ops/guest-control/src/protocol.rs create mode 100644 ops/guest-control/src/server.rs create mode 100644 ops/guest-control/src/validate.rs diff --git a/.gitignore b/.gitignore index 96c6e0d..ea5992d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ artifacts/ .env .DS_Store __pycache__/ +ops/guest-control/target/ diff --git a/ops/bloom/guest-bootstrap.sh b/ops/bloom/guest-bootstrap.sh index 84776b3..ade82bf 100755 --- a/ops/bloom/guest-bootstrap.sh +++ b/ops/bloom/guest-bootstrap.sh @@ -4,9 +4,13 @@ set -eu # Provision Bloom inside a workspace using the authenticated login address as a # watch-only wallet. This helper has no direct signer path: it accepts one EVM # address, never a key/passphrase, and refuses to run beside signer state. -# Transaction signing uses Bloom's native outbox system: the guest controller -# reads pending outbox entries from bloom serve via IPC and relays plan.md to -# the user's browser for approval. Private keys never enter the workspace VM. +# +# Transaction signing uses Bloom's Sealed Approval ceremony: when a workspace +# process stages a transaction, Bloom writes plan.md and approval_challenge.json +# to the VFS outbox. The ceremony URL (http://localhost:18734/ceremony/) +# is surfaced to the user. The user must connect via SSH with port forwarding +# (-L 18734:localhost:18734) and open the ceremony URL in their local browser +# to complete the WebAuthn approval. Private keys never enter the workspace VM. BLOOM_BIN=${BLOOM_BIN:-/usr/local/bin/bloom} BLOOM_WORKSPACE_ROOT=/workspace diff --git a/ops/guest-control/Cargo.lock b/ops/guest-control/Cargo.lock new file mode 100644 index 0000000..ae2e135 --- /dev/null +++ b/ops/guest-control/Cargo.lock @@ -0,0 +1,167 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bloom-guest-control" +version = "1.0.0" +dependencies = [ + "base64", + "libc", + "once_cell", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/ops/guest-control/Cargo.toml b/ops/guest-control/Cargo.toml new file mode 100644 index 0000000..1b48b0d --- /dev/null +++ b/ops/guest-control/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bloom-guest-control" +version = "1.0.0" +edition = "2021" +description = "Bounded guest-side file, job, and Bloom status service for Bloom Workspaces" +license = "MIT" + +[[bin]] +name = "bloom-guest-control" +path = "src/main.rs" + +[profile.release] +opt-level = 3 +lto = true +strip = true +panic = "abort" +codegen-units = 1 + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +regex = "1" +libc = "0.2" +once_cell = "1" diff --git a/ops/guest-control/README.md b/ops/guest-control/README.md index a6a47ac..bf09b63 100644 --- a/ops/guest-control/README.md +++ b/ops/guest-control/README.md @@ -1,51 +1,41 @@ -# Guest control service +# bloom-guest-control -`bloom-guest-control.py` is the guest-owned implementation of protocol v1. It -supports bounded file chunks, structured jobs, absolute-cursor log reads, -process-group cancellation, watch-only Bloom status, one-time SSH/NFS -configuration, and outbox relay (reads pending outbox entries from bloom serve -via IPC and relays plan.md to the user's browser for approval). It has no TCP listener: the production transports are QEMU virtio-serial stdio, AF_VSOCK port -5001, and a mode-0600 guest-local Unix socket for `bloom-workspace`. Stdio and -socket transports may run concurrently and share one bounded job table. +A single Rust binary that serves as both the guest-side control daemon and the +guest-local CLI client. -The image should install: +## Server mode -- `bloom-guest-control.py` at `/usr/local/libexec/bloom-guest-control`; -- `bloom-workspace` at `/usr/local/bin/bloom-workspace`. +The server accepts the version-1 JSON-line protocol over AF_VSOCK, a guest-local +Unix socket, and/or stdio. It provides: -Start the controller as root so an untrusted workspace process cannot signal or -ptrace it: +- Bounded file CRUD (`fs.list`, `fs.read`, `fs.write`, `fs.delete`) +- Structured job execution (`job.start`, `job.status`, `job.cancel`) with + `prlimit` + `setpriv` isolation as the unprivileged workspace account +- Bloom status reporting (`bloom.status`) +- Ceremony pending scan (`ceremony.pending`) +- Connection configuration (`connections.configure`) + +Each job is exec'd via `prlimit` and `setpriv` as the unprivileged workspace +account with no capabilities and no-new-privileges. + +## Client mode + +When invoked with a subcommand (`status`, `hello`, `files`, `jobs`), the binary +runs as a guest-local CLI client that connects to the server's Unix socket. + +## Building ```sh -/usr/local/libexec/bloom-guest-control \ - --workspace /workspace \ - --workspace-quota-bytes 134217728 \ - --job-uid 1000 --job-gid 1000 \ - --unix-socket /run/bloom/guest-control.sock \ - --vsock-port 5001 +cargo build --release ``` -For QEMU, replace `--vsock-port 5001` with `--stdio` while retaining the Unix -socket for the guest helper. `.` is the explicit `/workspace` sentinel for -directory listing and job cwd only; file read/write/delete operations require a -non-root relative path. File write/delete responses include current -`usedBytes` and `quotaBytes` without returning file content. - -Jobs never pass through a shell added by the service. The requested argv is -executed by `prlimit` and `setpriv`: UID/GID 1000, empty capability sets, -no-new-privileges, a private process group, 64 processes, 64 descriptors, no -core dumps, and 64 MiB per-file output. User-provided environment keys are -limited to documented safe names and `APP_`, `JOB_`, or `TEST_` namespaces. -The controller copies only the operator's controlled proxy and CA path settings; -it does not inherit wallet, control-plane, or host credentials. - -`bloom.status` reports only the public watch address, whether `/bloom` is -mounted, and capability flags. It -never reads or returns passphrases, private keys, cookies, or session material. - -`connections.configure` is accepted once per guest scope and only by the -root-owned controller. It validates an Ed25519 CA **public** key, creates an -ephemeral host key, writes wallet/workspace principals, and starts the locked -guest sshd. With the verified QEMU NFSD kernel and a persistent volume it may -also export guest-loopback NFSv4. The CA private key and user private key never -enter the guest. +For the Alpine guest image (static musl): + +```sh +cargo build --release --target x86_64-unknown-linux-musl +``` + +## Binary layout in the guest image + +- `bloom-guest-control` at `/usr/local/libexec/bloom-guest-control` (server mode) +- `bloom-workspace` at `/usr/local/bin/bloom-workspace` (client mode — same binary) diff --git a/ops/guest-control/bloom-guest-control.py b/ops/guest-control/bloom-guest-control.py deleted file mode 100755 index 67b4bd1..0000000 --- a/ops/guest-control/bloom-guest-control.py +++ /dev/null @@ -1,1157 +0,0 @@ -#!/usr/bin/env python3 -"""Bounded guest-side file, job, and Bloom status service. - -The service is intentionally dependency-free. It accepts the version-1 -JSON-line protocol over AF_VSOCK and/or a guest-local Unix socket. The service -may run as root so jobs cannot signal or ptrace it, but every job is exec'd via -prlimit + setpriv as the unprivileged workspace account with no capabilities -and no-new-privileges. -""" - -from __future__ import annotations - -import argparse -import base64 -import binascii -import hashlib -import errno -import json -import os -import posixpath -import re -import select -import shutil -import signal -import socket -import stat -import subprocess -import threading -import time -import uuid -from collections import OrderedDict -from dataclasses import dataclass, field -from typing import Any, BinaryIO - - -PROTOCOL_VERSION = 1 -MAX_FRAME_BYTES = 384 * 1024 -MAX_FILE_CHUNK_BYTES = 256 * 1024 -MAX_FILE_BYTES = 8 * 1024 * 1024 -MAX_LOG_CHUNK_BYTES = 256 * 1024 -MAX_LOG_BYTES = 1024 * 1024 -MAX_LIST_ENTRIES = 1000 -MAX_SCAN_ENTRIES = 20_000 -MAX_ACTIVE_JOBS = 2 -MAX_RETAINED_JOBS = 64 -MAX_JOB_PROCESSES = 64 -MAX_JOB_FILE_BYTES = 64 * 1024 * 1024 -MAX_JOB_TIMEOUT_MS = 2 * 60 * 60 * 1000 -JOB_KILL_GRACE_SECONDS = 1.0 -BLOOM_SOCKET_PATH = os.environ.get("BLOOM_IPC_SOCKET", "/workspace/.bloom/run/bloom.sock") -MAX_OUTBOX_PLAN_BYTES = 64 * 1024 -MAX_OUTBOX_CHAINS = 16 -MAX_OUTBOX_PENDING = 32 - -REQUEST_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") -ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") -EVM_ADDRESS = re.compile(r"^0x[0-9a-f]{40}$") -SSH_CA_PUBLIC_KEY = re.compile(r"^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$") -OUTBOX_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") -WORKSPACE_ID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") -USER_ENV_EXACT = frozenset( - { - "CI", - "DEBUG", - "FORCE_COLOR", - "LANG", - "LC_ALL", - "LOG_LEVEL", - "NODE_ENV", - "NO_COLOR", - "PYTHONUNBUFFERED", - "RUST_BACKTRACE", - "RUST_LOG", - "TERM", - "TZ", - } -) -USER_ENV_PREFIXES = ("APP_", "JOB_", "TEST_") -SYSTEM_PROXY_ENV = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy") - - -class ControlError(Exception): - def __init__(self, code: str, message: str): - super().__init__(message) - self.code = code - - -def now_ms() -> int: - return int(time.time() * 1000) - - -def require(condition: bool, code: str, message: str) -> None: - if not condition: - raise ControlError(code, message) - - -def require_exact_keys(value: dict[str, Any], expected: set[str]) -> None: - require(set(value) == expected, "invalid_request", "request fields do not match the operation contract") - - -def validate_request(raw: Any) -> dict[str, Any]: - require(isinstance(raw, dict), "invalid_request", "request must be an object") - require(raw.get("version") == PROTOCOL_VERSION, "invalid_request", "unsupported guest protocol version") - require(isinstance(raw.get("id"), str) and REQUEST_ID.fullmatch(raw["id"]), "invalid_request", "invalid request id") - operation = raw.get("operation") - require(isinstance(operation, str), "invalid_request", "operation is required") - base = {"version", "id", "operation"} - fields = { - "hello": base, - "fs.list": base | {"path"}, - "fs.read": base | {"path", "offset", "maxBytes"}, - "fs.write": base | {"path", "offset", "data", "truncate"}, - "fs.delete": base | {"path", "recursive"}, - "job.start": base | {"jobId", "argv", "cwd", "environment", "timeoutMs"}, - "job.status": base | {"jobId", "logOffset", "maxBytes"}, - "job.cancel": base | {"jobId"}, - "bloom.status": base, - "connections.configure": base | {"workspaceId", "wallet", "caPublicKey", "nfs"}, - "outbox.pending": base, - "outbox.confirm": base | {"txId", "chain", "wallet", "confirmText"}, - "outbox.cancel": base | {"txId", "chain", "wallet"}, - } - require(operation in fields, "invalid_request", "unknown guest operation") - require_exact_keys(raw, fields[operation]) - return raw - - -def validate_relative_path(value: Any) -> list[str]: - require(isinstance(value, str), "invalid_request", "workspace path must be a string") - require(0 < len(value.encode("utf-8")) <= 1024, "invalid_request", "workspace path has an invalid length") - require("\x00" not in value and "\\" not in value, "invalid_request", "workspace path contains forbidden characters") - require(not value.startswith("/") and not value.endswith("/"), "invalid_request", "workspace path must be relative") - require(posixpath.normpath(value) == value and value not in (".", "..") and not value.startswith("../"), "invalid_request", "workspace path escapes /workspace") - parts = value.split("/") - require(all(part not in ("", ".", "..") for part in parts), "invalid_request", "invalid workspace path") - return parts - - -def validate_workspace_directory(value: Any) -> list[str]: - if value == ".": - return [] - return validate_relative_path(value) - - -def validate_integer(value: Any, minimum: int, maximum: int, label: str) -> int: - require(isinstance(value, int) and not isinstance(value, bool), "invalid_request", f"{label} must be an integer") - require(minimum <= value <= maximum, "invalid_request", f"{label} is outside the allowed range") - return value - - -def validate_job_id(value: Any) -> str: - require(isinstance(value, str), "invalid_request", "job id must be a UUID") - try: - parsed = uuid.UUID(value) - except (ValueError, AttributeError): - raise ControlError("invalid_request", "job id must be a UUID") from None - require(str(parsed) == value, "invalid_request", "job id must be a canonical lowercase UUID") - return value - - -def validate_environment(value: Any) -> dict[str, str]: - require(isinstance(value, dict) and len(value) <= 64, "invalid_request", "job environment must contain at most 64 variables") - result: dict[str, str] = {} - total = 0 - for name, item in value.items(): - require(isinstance(name, str) and ENV_NAME.fullmatch(name), "invalid_request", "job environment contains an invalid name") - require(name in USER_ENV_EXACT or name.startswith(USER_ENV_PREFIXES), "permission_denied", f"job environment variable is not allowlisted: {name}") - require(isinstance(item, str) and "\x00" not in item, "invalid_request", f"job environment value is invalid: {name}") - encoded = item.encode("utf-8") - require(len(encoded) <= 8192, "limit_exceeded", f"job environment value is too large: {name}") - total += len(name.encode("ascii")) + len(encoded) - require(total <= 32 * 1024, "limit_exceeded", "job environment exceeds the aggregate size limit") - result[name] = item - return result - - -class WorkspaceFiles: - def __init__(self, root: str, quota_bytes: int, owner_uid: int, owner_gid: int): - self.root = os.path.abspath(root) - root_metadata = os.lstat(self.root) - if stat.S_ISLNK(root_metadata.st_mode) or not stat.S_ISDIR(root_metadata.st_mode): - raise RuntimeError("workspace root must be a non-symlink directory") - root_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) - try: - self.root_fd = os.open(self.root, root_flags) - except OSError as error: - raise RuntimeError(f"unsafe or unavailable workspace root: {error}") from error - self.quota_bytes = quota_bytes - self.owner_uid = owner_uid - self.owner_gid = owner_gid - self.write_lock = threading.Lock() - - def close(self) -> None: - os.close(self.root_fd) - - def prepare_job_tmp(self) -> None: - descriptor = self._open_directory([".tmp"], create=True) - try: - os.fchmod(descriptor, 0o700) - if os.geteuid() == 0: - os.fchown(descriptor, self.owner_uid, self.owner_gid) - finally: - os.close(descriptor) - - def _open_directory(self, parts: list[str], create: bool = False) -> int: - current = os.dup(self.root_fd) - flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) - try: - for part in parts: - if create: - try: - os.mkdir(part, mode=0o700, dir_fd=current) - if os.geteuid() == 0: - os.chown(part, self.owner_uid, self.owner_gid, dir_fd=current, follow_symlinks=False) - except FileExistsError: - pass - following = os.open(part, flags, dir_fd=current) - os.close(current) - current = following - return current - except Exception: - os.close(current) - raise - - def _open_file(self, parts: list[str], flags: int, mode: int = 0o600, create_parents: bool = False) -> int: - parent = self._open_directory(parts[:-1], create=create_parents) - try: - return os.open(parts[-1], flags | getattr(os, "O_NOFOLLOW", 0), mode, dir_fd=parent) - finally: - os.close(parent) - - def list(self, path: Any) -> dict[str, Any]: - parts = validate_workspace_directory(path) - directory = self._open_directory(parts) - try: - names = sorted(os.listdir(directory)) - require(len(names) <= MAX_LIST_ENTRIES, "limit_exceeded", "directory contains too many entries") - entries = [] - for name in names: - metadata = os.stat(name, dir_fd=directory, follow_symlinks=False) - kind = "symlink" if stat.S_ISLNK(metadata.st_mode) else "directory" if stat.S_ISDIR(metadata.st_mode) else "file" - entries.append( - { - "path": "/".join(parts + [name]), - "type": kind, - "size": metadata.st_size if stat.S_ISREG(metadata.st_mode) else 0, - "modifiedAt": int(metadata.st_mtime * 1000), - } - ) - return {"files": entries} - finally: - os.close(directory) - - def read(self, path: Any, offset: Any, max_bytes: Any) -> dict[str, Any]: - parts = validate_relative_path(path) - start = validate_integer(offset, 0, MAX_FILE_BYTES, "file offset") - limit = validate_integer(max_bytes, 1, MAX_FILE_CHUNK_BYTES, "file read size") - descriptor = self._open_file(parts, os.O_RDONLY) - try: - metadata = os.fstat(descriptor) - require(stat.S_ISREG(metadata.st_mode), "invalid_request", "download path is not a regular file") - require(metadata.st_size <= MAX_FILE_BYTES, "limit_exceeded", "file exceeds the download limit") - require(start <= metadata.st_size, "invalid_request", "file offset exceeds the file size") - chunk = os.pread(descriptor, limit, start) - return { - "path": "/".join(parts), - "offset": start, - "nextOffset": start + len(chunk), - "size": metadata.st_size, - "eof": start + len(chunk) >= metadata.st_size, - "data": base64.b64encode(chunk).decode("ascii"), - } - finally: - os.close(descriptor) - - def write(self, path: Any, offset: Any, encoded: Any, truncate: Any) -> dict[str, Any]: - parts = validate_relative_path(path) - start = validate_integer(offset, 0, MAX_FILE_BYTES, "file offset") - require(isinstance(encoded, str), "invalid_request", "file payload must be base64") - require(isinstance(truncate, bool), "invalid_request", "truncate must be boolean") - require(not truncate or start == 0, "invalid_request", "a truncating write must begin at offset zero") - try: - chunk = base64.b64decode(encoded, validate=True) - except (binascii.Error, ValueError): - raise ControlError("invalid_request", "file payload is not canonical base64") from None - require(base64.b64encode(chunk).decode("ascii") == encoded, "invalid_request", "file payload is not canonical base64") - require(len(chunk) <= MAX_FILE_CHUNK_BYTES, "limit_exceeded", "file write chunk is too large") - require(start + len(chunk) <= MAX_FILE_BYTES, "limit_exceeded", "file exceeds the upload limit") - with self.write_lock: - flags = os.O_WRONLY | os.O_CREAT - descriptor = self._open_file(parts, flags, create_parents=True) - try: - if os.geteuid() == 0: - os.fchown(descriptor, self.owner_uid, self.owner_gid) - metadata = os.fstat(descriptor) - require(stat.S_ISREG(metadata.st_mode), "invalid_request", "upload target is not a regular file") - prior = metadata.st_size - used = self._directory_bytes() - resulting_size = max(0 if truncate else prior, start + len(chunk)) - require(used - prior + resulting_size <= self.quota_bytes, "limit_exceeded", "workspace storage quota exceeded") - if truncate: - os.ftruncate(descriptor, 0) - written = 0 - while written < len(chunk): - count = os.pwrite(descriptor, chunk[written:], start + written) - require(count > 0, "internal", "file write made no progress") - written += count - os.fsync(descriptor) - final_size = os.fstat(descriptor).st_size - finally: - os.close(descriptor) - return { - "path": "/".join(parts), - "size": final_size, - "nextOffset": start + len(chunk), - "usedBytes": used - prior + resulting_size, - "quotaBytes": self.quota_bytes, - } - - def delete(self, path: Any, recursive: Any) -> dict[str, Any]: - parts = validate_relative_path(path) - require(recursive is False, "permission_denied", "recursive deletion is not supported") - with self.write_lock: - parent = self._open_directory(parts[:-1]) - try: - metadata = os.stat(parts[-1], dir_fd=parent, follow_symlinks=False) - require(stat.S_ISREG(metadata.st_mode), "invalid_request", "delete path is not a regular file") - os.unlink(parts[-1], dir_fd=parent) - finally: - os.close(parent) - used_after = self._directory_bytes() - return { - "path": "/".join(parts), - "deleted": True, - "usedBytes": used_after, - "quotaBytes": self.quota_bytes, - } - - def open_job_cwd(self, path: Any) -> int: - return self._open_directory(validate_workspace_directory(path)) - - def read_small(self, path: str, maximum: int = 256) -> str | None: - try: - descriptor = self._open_file(validate_relative_path(path), os.O_RDONLY) - try: - metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: - return None - return os.read(descriptor, maximum).decode("utf-8").strip() - finally: - os.close(descriptor) - except (OSError, ControlError, UnicodeDecodeError): - return None - - def watch_identity(self) -> str | None: - """Return a validated public watch address, rejecting any extra state.""" - try: - keystore = self._open_directory([".bloom", "keystore"]) - try: - if sorted(os.listdir(keystore)) != ["workspace-login"]: - return None - finally: - os.close(keystore) - wallet = self._open_directory([".bloom", "keystore", "workspace-login"]) - try: - if sorted(os.listdir(wallet)) != ["address", "kind", "pubkey"]: - return None - finally: - os.close(wallet) - kind = self.read_small(".bloom/keystore/workspace-login/kind") - address = self.read_small(".bloom/keystore/workspace-login/address") - public_key = self.read_small(".bloom/keystore/workspace-login/pubkey") - normalized = address.lower() if address else None - if kind != "watch" or public_key != "" or not isinstance(normalized, str) or EVM_ADDRESS.fullmatch(normalized) is None: - return None - return normalized - except (OSError, ControlError): - return None - - def _directory_bytes(self) -> int: - count = 0 - total = 0 - for _, directories, files, directory_fd in os.fwalk(self.root, topdown=True, follow_symlinks=False): - directories[:] = [name for name in directories if not stat.S_ISLNK(os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode)] - for name in files: - count += 1 - require(count <= MAX_SCAN_ENTRIES, "limit_exceeded", "workspace contains too many files") - metadata = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) - if stat.S_ISREG(metadata.st_mode): - total += metadata.st_size - return total - - -class LogRing: - def __init__(self) -> None: - self.data = bytearray() - self.start_offset = 0 - self.end_offset = 0 - self.lock = threading.Lock() - - def append(self, chunk: bytes) -> None: - if not chunk: - return - with self.lock: - self.data.extend(chunk) - self.end_offset += len(chunk) - overflow = len(self.data) - MAX_LOG_BYTES - if overflow > 0: - del self.data[:overflow] - self.start_offset += overflow - - def slice(self, requested_offset: int, maximum: int, terminal: bool) -> dict[str, Any]: - with self.lock: - require(requested_offset <= self.end_offset, "invalid_request", "log cursor is beyond the current log end") - offset = max(requested_offset, self.start_offset) - relative = offset - self.start_offset - chunk = bytes(self.data[relative : relative + maximum]) - next_offset = offset + len(chunk) - return { - "offset": offset, - "nextOffset": next_offset, - "endOffset": self.end_offset, - "truncatedBefore": requested_offset < self.start_offset, - "eof": terminal and next_offset == self.end_offset, - "encoding": "base64", - "data": base64.b64encode(chunk).decode("ascii"), - } - - -TERMINAL_STATES = frozenset({"succeeded", "failed", "cancelled", "timed_out"}) - - -@dataclass -class Job: - job_id: str - argv: list[str] - cwd: str - timeout_ms: int - created_at: int - state: str = "queued" - started_at: int | None = None - finished_at: int | None = None - exit_code: int | None = None - signal_number: int | None = None - process: subprocess.Popen[bytes] | None = None - cancel_requested: bool = False - logs: LogRing = field(default_factory=LogRing) - finished: threading.Event = field(default_factory=threading.Event) - - -class JobEngine: - def __init__(self, files: WorkspaceFiles, job_uid: int, job_gid: int): - self.files = files - self.job_uid = job_uid - self.job_gid = job_gid - self.jobs: OrderedDict[str, Job] = OrderedDict() - self.lock = threading.RLock() - - def start(self, request: dict[str, Any]) -> dict[str, Any]: - job_id = validate_job_id(request["jobId"]) - argv = request["argv"] - require(isinstance(argv, list) and 1 <= len(argv) <= 64, "invalid_request", "argv must contain between 1 and 64 arguments") - validated_argv = [] - total_argv = 0 - for argument in argv: - require(isinstance(argument, str) and argument and "\x00" not in argument, "invalid_request", "argv contains an invalid argument") - size = len(argument.encode("utf-8")) - require(size <= 4096, "limit_exceeded", "argv contains an oversized argument") - total_argv += size - require(total_argv <= 32 * 1024, "limit_exceeded", "argv exceeds the aggregate size limit") - validated_argv.append(argument) - cwd_parts = validate_workspace_directory(request["cwd"]) - cwd = "/".join(cwd_parts) if cwd_parts else "." - timeout_ms = validate_integer(request["timeoutMs"], 1000, MAX_JOB_TIMEOUT_MS, "job timeout") - user_environment = validate_environment(request["environment"]) - launcher = self._launcher_argv(validated_argv) - - with self.lock: - require(job_id not in self.jobs, "conflict", "job id already exists") - active = sum(job.state not in TERMINAL_STATES for job in self.jobs.values()) - require(active < MAX_ACTIVE_JOBS, "limit_exceeded", "workspace already has the maximum number of active jobs") - self._prune_terminal_jobs() - require(len(self.jobs) < MAX_RETAINED_JOBS, "limit_exceeded", "workspace has too many retained jobs") - cwd_fd = self.files.open_job_cwd(cwd) - job = Job(job_id=job_id, argv=validated_argv, cwd=cwd, timeout_ms=timeout_ms, created_at=now_ms()) - self.jobs[job_id] = job - try: - job.started_at = now_ms() - process = subprocess.Popen( - launcher, - cwd=f"/proc/self/fd/{cwd_fd}", - env=self._job_environment(user_environment), - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - close_fds=True, - pass_fds=(cwd_fd,), - start_new_session=True, - ) - except (OSError, subprocess.SubprocessError) as error: - job.state = "failed" - job.finished_at = now_ms() - job.logs.append(f"bloom job launch failed: {error}\n".encode("utf-8", "replace")) - job.finished.set() - return self._status(job, 0, MAX_LOG_CHUNK_BYTES) - finally: - os.close(cwd_fd) - job.process = process - job.state = "running" - threading.Thread(target=self._capture_output, args=(job,), daemon=True, name=f"job-log-{job_id}").start() - threading.Thread(target=self._wait_for_job, args=(job,), daemon=True, name=f"job-wait-{job_id}").start() - return self._status(job, 0, MAX_LOG_CHUNK_BYTES) - - def status(self, request: dict[str, Any]) -> dict[str, Any]: - job = self._get(validate_job_id(request["jobId"])) - offset = validate_integer(request["logOffset"], 0, 2**53 - 1, "log cursor") - maximum = validate_integer(request["maxBytes"], 1, MAX_LOG_CHUNK_BYTES, "log read size") - with self.lock: - return self._status(job, offset, maximum) - - def cancel(self, request: dict[str, Any]) -> dict[str, Any]: - job = self._get(validate_job_id(request["jobId"])) - with self.lock: - if job.state in TERMINAL_STATES: - return self._status(job, job.logs.end_offset, 1) - process = job.process - require(process is not None, "conflict", "job has not started") - if process.poll() is None: - first_request = not job.cancel_requested - job.cancel_requested = True - self._signal_group(process.pid, signal.SIGTERM) - if first_request: - threading.Thread(target=self._force_cancel, args=(job,), daemon=True, name=f"job-cancel-{job.job_id}").start() - return self._status(job, job.logs.end_offset, 1) - - def close(self) -> None: - with self.lock: - active = [job for job in self.jobs.values() if job.state not in TERMINAL_STATES and job.process] - for job in active: - job.cancel_requested = True - self._signal_group(job.process.pid, signal.SIGKILL) - for job in active: - job.finished.wait(2) - - def _get(self, job_id: str) -> Job: - with self.lock: - job = self.jobs.get(job_id) - require(job is not None, "not_found", "job does not exist") - return job - - def _prune_terminal_jobs(self) -> None: - while len(self.jobs) >= MAX_RETAINED_JOBS: - terminal_id = next((job_id for job_id, job in self.jobs.items() if job.state in TERMINAL_STATES), None) - if terminal_id is None: - return - del self.jobs[terminal_id] - - def _capture_output(self, job: Job) -> None: - stream = job.process.stdout if job.process else None - if stream is None: - return - try: - while True: - chunk = stream.read(16 * 1024) - if not chunk: - return - job.logs.append(chunk) - finally: - stream.close() - - def _wait_for_job(self, job: Job) -> None: - process = job.process - if process is None: - return - timed_out = False - try: - try: - process.wait(timeout=job.timeout_ms / 1000) - except subprocess.TimeoutExpired: - timed_out = True - self._signal_group(process.pid, signal.SIGTERM) - try: - process.wait(timeout=JOB_KILL_GRACE_SECONDS) - except subprocess.TimeoutExpired: - self._signal_group(process.pid, signal.SIGKILL) - process.wait() - if process.stdout: - # Give the bounded reader a chance to drain the closed pipe. - for _ in range(100): - if process.stdout.closed: - break - time.sleep(0.01) - with self.lock: - return_code = process.returncode - job.finished_at = now_ms() - if timed_out: - job.state = "timed_out" - elif job.cancel_requested: - job.state = "cancelled" - elif return_code == 0: - job.state = "succeeded" - else: - job.state = "failed" - if return_code is not None and return_code < 0: - job.signal_number = -return_code - else: - job.exit_code = return_code - finally: - job.finished.set() - - def _force_cancel(self, job: Job) -> None: - time.sleep(JOB_KILL_GRACE_SECONDS) - process = job.process - if process is not None and process.poll() is None: - self._signal_group(process.pid, signal.SIGKILL) - - def _status(self, job: Job, log_offset: int, max_bytes: int) -> dict[str, Any]: - result: dict[str, Any] = { - "jobId": job.job_id, - "state": job.state, - "createdAt": job.created_at, - "startedAt": job.started_at, - "finishedAt": job.finished_at, - "exitCode": job.exit_code, - "signal": job.signal_number, - "timeoutMs": job.timeout_ms, - "logs": job.logs.slice(log_offset, max_bytes, job.state in TERMINAL_STATES), - } - return result - - def _launcher_argv(self, argv: list[str]) -> list[str]: - prlimit = shutil.which("prlimit") or "/usr/bin/prlimit" - setpriv = shutil.which("setpriv") or "/usr/bin/setpriv" - command = [ - prlimit, - "--nofile=64:64", - f"--fsize={MAX_JOB_FILE_BYTES}:{MAX_JOB_FILE_BYTES}", - "--core=0:0", - ] - if os.geteuid() == 0: - command.append(f"--nproc={MAX_JOB_PROCESSES}:{MAX_JOB_PROCESSES}") - command.extend([ - "--", - setpriv, - "--no-new-privs", - "--pdeathsig=SIGKILL", - ]) - if os.geteuid() == 0: - command.extend([ - "--bounding-set=-all", - "--inh-caps=-all", - "--ambient-caps=-all", - f"--reuid={self.job_uid}", - f"--regid={self.job_gid}", - "--clear-groups", - ]) - else: - require(os.geteuid() == self.job_uid and os.getegid() == self.job_gid, "unavailable", "guest control must run as root to change job identity") - return command + ["--"] + argv - - def _job_environment(self, user_environment: dict[str, str]) -> dict[str, str]: - environment = { - "HOME": "/workspace", - "USER": "workspace", - "LOGNAME": "workspace", - "SHELL": "/bin/bash", - "PATH": "/usr/local/bin:/usr/bin:/bin", - "TMPDIR": "/workspace/.tmp", - "LANG": "C.UTF-8", - } - for name in SYSTEM_PROXY_ENV: - value = os.environ.get(name) - if value and len(value) <= 2048 and "@" not in value and "\x00" not in value: - environment[name] = value - for name in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"): - value = os.environ.get(name) - if value and value.startswith("/") and "\x00" not in value and len(value) <= 1024: - environment[name] = value - environment.update(user_environment) - return environment - - @staticmethod - def _signal_group(pid: int, requested_signal: signal.Signals) -> None: - try: - os.killpg(pid, requested_signal) - except ProcessLookupError: - pass - - -def write_private_file(path: str, content: str, mode: int) -> None: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), mode) - try: - os.fchmod(descriptor, mode) - encoded = content.encode("utf-8") - written = 0 - while written < len(encoded): - count = os.write(descriptor, encoded[written:]) - if count <= 0: - raise OSError("connection configuration write made no progress") - written += count - os.fsync(descriptor) - finally: - os.close(descriptor) - - -def read_small_regular_file(path: str, maximum: int) -> str | None: - try: - descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) - try: - metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: - return None - return os.read(descriptor, maximum).decode("utf-8").strip() - finally: - os.close(descriptor) - except (OSError, UnicodeDecodeError): - return None - - -class GuestControl: - def __init__(self, files: WorkspaceFiles, jobs: JobEngine): - self.files = files - self.jobs = jobs - self.sshd: subprocess.Popen[bytes] | None = None - self.mountd: subprocess.Popen[bytes] | None = None - self.connection_scope: tuple[str, str, bool] | None = None - self.connection_lock = threading.Lock() - - def handle(self, raw: Any) -> dict[str, Any]: - request_id = raw.get("id") if isinstance(raw, dict) and isinstance(raw.get("id"), str) and REQUEST_ID.fullmatch(raw["id"]) else "invalid" - try: - request = validate_request(raw) - operation = request["operation"] - if operation == "hello": - result = { - "protocolVersion": PROTOCOL_VERSION, - "operations": ["fs.list", "fs.read", "fs.write", "fs.delete", "job.start", "job.status", "job.cancel", "bloom.status", "connections.configure", "outbox.pending", "outbox.confirm", "outbox.cancel"], - "limits": { - "fileChunkBytes": MAX_FILE_CHUNK_BYTES, - "fileBytes": MAX_FILE_BYTES, - "activeJobs": MAX_ACTIVE_JOBS, - "retainedJobs": MAX_RETAINED_JOBS, - "jobProcesses": MAX_JOB_PROCESSES, - "jobLogBytes": MAX_LOG_BYTES, - "jobTimeoutMs": MAX_JOB_TIMEOUT_MS, - }, - } - elif operation == "fs.list": - result = self.files.list(request["path"]) - elif operation == "fs.read": - result = self.files.read(request["path"], request["offset"], request["maxBytes"]) - elif operation == "fs.write": - result = self.files.write(request["path"], request["offset"], request["data"], request["truncate"]) - elif operation == "fs.delete": - result = self.files.delete(request["path"], request["recursive"]) - elif operation == "job.start": - result = self.jobs.start(request) - elif operation == "job.status": - result = self.jobs.status(request) - elif operation == "job.cancel": - result = self.jobs.cancel(request) - elif operation == "bloom.status": - result = self._bloom_status() - elif operation == "connections.configure": - result = self._configure_connections(request) - elif operation == "outbox.pending": - result = self._outbox_pending() - elif operation == "outbox.confirm": - result = self._outbox_confirm(request) - elif operation == "outbox.cancel": - result = self._outbox_cancel(request) - else: - raise ControlError("invalid_request", "unknown guest operation") - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": True, "result": result} - except ControlError as error: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": error.code, "message": str(error)[:1024]}} - except FileNotFoundError: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": "not_found", "message": "workspace path does not exist"}} - except PermissionError: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": "permission_denied", "message": "workspace operation was denied"}} - except OSError as error: - code = "not_found" if error.errno == errno.ENOENT else "permission_denied" if error.errno in (errno.EACCES, errno.EPERM, errno.ELOOP, errno.ENOTDIR) else "internal" - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": code, "message": "workspace operation failed"}} - except Exception: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": "internal", "message": "guest control operation failed"}} - - def _bloom_status(self) -> dict[str, Any]: - address = self.files.watch_identity() - watch_identity = address is not None - executable = shutil.which("bloom") is not None - return { - "available": executable and watch_identity, - "mount": {"path": "/bloom", "mounted": os.path.ismount("/bloom")}, - "identity": {"kind": "watch", "address": address} if watch_identity else None, - "capabilities": { - "files": True, - "jobs": True, - "bloomRead": executable and watch_identity, - "walletSigning": True, - "transactions": True, - }, - "helper": {"name": "bloom-workspace", "protocolVersion": PROTOCOL_VERSION}, - } - - @staticmethod - def _validate_outbox_identifier(value: Any, label: str, max_len: int = 64) -> None: - """Validate that an outbox identifier is safe for VFS path interpolation.""" - require( - isinstance(value, str) - and 1 <= len(value) <= max_len - and OUTBOX_ID.fullmatch(value) is not None, - "invalid_request", - f"invalid {label}", - ) - - def _bloom_ipc(self, method: str, params: dict[str, Any]) -> Any: - """Call bloom serve's IPC socket.""" - require(os.path.exists(BLOOM_SOCKET_PATH), "unavailable", "Bloom IPC socket is not available") - request = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} - frame = json.dumps(request, separators=(",", ":")).encode() + b"\n" - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: - client.settimeout(10.0) - client.connect(BLOOM_SOCKET_PATH) - client.sendall(frame) - response = bytearray() - while b"\n" not in response: - chunk = client.recv(65536) - if not chunk: - break - response.extend(chunk) - if len(response) > 1024 * 1024: - break - decoded = json.loads(bytes(response).decode()) - if "error" in decoded: - raise ControlError("internal", f"bloom IPC error: {decoded['error']}") - return decoded.get("result") - - def _outbox_pending(self) -> dict[str, Any]: - """List pending outbox transactions by scanning the bloom VFS via IPC.""" - wallet_result = self._bloom_ipc("list", {"path": "/wallets"}) - wallets = [e.get("name") for e in (wallet_result.get("entries") or []) if e.get("type") == "dir"] - pending = [] - for wallet in wallets[:MAX_OUTBOX_CHAINS]: - chain_result = self._bloom_ipc("list", {"path": f"/wallets/{wallet}/chains"}) - chains = [e.get("name") for e in (chain_result.get("entries") or []) if e.get("type") == "dir"] - for chain in chains[:MAX_OUTBOX_CHAINS]: - pending_result = self._bloom_ipc("list", {"path": f"/wallets/{wallet}/chains/{chain}/outbox/pending"}) - ids = [e.get("name") for e in (pending_result.get("entries") or []) if e.get("type") == "dir"] - for tx_id in ids[:MAX_OUTBOX_PENDING]: - plan_result = self._bloom_ipc("read", {"path": f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/plan.md"}) - plan_b64 = plan_result.get("bytes_b64", "") - plan_md = base64.b64decode(plan_b64).decode("utf-8", errors="replace")[:MAX_OUTBOX_PLAN_BYTES] - pending.append({"id": tx_id, "chain": chain, "wallet": wallet, "planMd": plan_md}) - return {"requests": pending} - - def _outbox_confirm(self, request: dict[str, Any]) -> dict[str, Any]: - """Write confirm to bloom's outbox via IPC.""" - tx_id = request["txId"] - chain = request["chain"] - wallet = request["wallet"] - confirm_text = request["confirmText"] - self._validate_outbox_identifier(tx_id, "tx id") - self._validate_outbox_identifier(chain, "chain", max_len=32) - self._validate_outbox_identifier(wallet, "wallet") - require(isinstance(confirm_text, str) and len(confirm_text.strip()) > 0, "invalid_request", "confirm text is required") - path = f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/confirm" - body_b64 = base64.b64encode(confirm_text.encode()).decode() - self._bloom_ipc("write", {"path": path, "bytes_b64": body_b64}) - return {} - - def _outbox_cancel(self, request: dict[str, Any]) -> dict[str, Any]: - """Write cancel to bloom's outbox via IPC.""" - tx_id = request["txId"] - chain = request["chain"] - wallet = request["wallet"] - self._validate_outbox_identifier(tx_id, "tx id") - self._validate_outbox_identifier(chain, "chain", max_len=32) - self._validate_outbox_identifier(wallet, "wallet") - path = f"/wallets/{wallet}/chains/{chain}/outbox/pending/{tx_id}/cancel" - body_b64 = base64.b64encode(b"cancel").decode() - self._bloom_ipc("write", {"path": path, "bytes_b64": body_b64}) - return {} - - def _configure_connections(self, request: dict[str, Any]) -> dict[str, Any]: - with self.connection_lock: - return self._configure_connections_locked(request) - - def _configure_connections_locked(self, request: dict[str, Any]) -> dict[str, Any]: - workspace_id = request["workspaceId"] - wallet = request["wallet"] - ca_public_key = request["caPublicKey"] - nfs_enabled = request["nfs"] - require(isinstance(workspace_id, str) and WORKSPACE_ID.fullmatch(workspace_id), "invalid_request", "invalid workspace id") - require(isinstance(wallet, str) and EVM_ADDRESS.fullmatch(wallet), "invalid_request", "invalid workspace wallet") - require(isinstance(ca_public_key, str) and SSH_CA_PUBLIC_KEY.fullmatch(ca_public_key), "invalid_request", "invalid SSH CA public key") - require(isinstance(nfs_enabled, bool), "invalid_request", "invalid NFS capability") - scope = (workspace_id, wallet, nfs_enabled) - if self.connection_scope is not None: - require(self.connection_scope == scope, "conflict", "workspace connection scope is already configured") - return self._connection_status(workspace_id, nfs_enabled) - require(os.geteuid() == 0, "unavailable", "workspace connections require the root guest controller") - require(shutil.which("sshd") is not None and shutil.which("ssh-keygen") is not None, "unavailable", "OpenSSH server tooling is unavailable") - - try: - decoded = base64.b64decode(ca_public_key.split(" ", 1)[1], validate=True) - except (binascii.Error, ValueError): - raise ControlError("invalid_request", "invalid SSH CA public key") from None - require(32 <= len(decoded) <= 128, "invalid_request", "invalid SSH CA public key payload") - directory = "/run/bloom/ssh" - os.makedirs(directory, mode=0o700, exist_ok=True) - os.chmod(directory, 0o700) - ca_path = f"{directory}/user_ca.pub" - principals_path = f"{directory}/authorized_principals" - host_key_path = f"{directory}/ssh_host_ed25519_key" - owner_digest = hashlib.sha256(wallet.encode("ascii")).hexdigest()[:32] - principals = [f"bloom-shell-{workspace_id}-w-{owner_digest}"] - if nfs_enabled: - principals.append(f"bloom-nfs-{workspace_id}-w-{owner_digest}") - write_private_file(ca_path, f"{ca_public_key}\n", 0o600) - write_private_file(principals_path, "\n".join(principals) + "\n", 0o600) - if not os.path.exists(host_key_path): - subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", host_key_path], check=True, timeout=10) - os.chmod(host_key_path, 0o600) - os.chmod(f"{host_key_path}.pub", 0o644) - shell = "/usr/local/libexec/bloom-workspace-shell" - require(os.path.isfile(shell) and not os.path.islink(shell), "unavailable", "workspace SSH shell helper is unavailable") - sshd_argv = [ - "sshd", "-D", "-e", "-f", "/etc/ssh/sshd_config", - "-o", f"HostKey={host_key_path}", "-o", f"TrustedUserCAKeys={ca_path}", - "-o", f"AuthorizedPrincipalsFile={principals_path}", "-o", "AuthorizedKeysFile=none", - "-o", "AuthenticationMethods=publickey", "-o", "PubkeyAuthentication=yes", - "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", - "-o", "PermitRootLogin=no", "-o", "AllowUsers=workspace", - "-o", "AllowAgentForwarding=no", "-o", "AllowTcpForwarding=local", - "-o", "PermitOpen=127.0.0.1:2049", "-o", "AllowStreamLocalForwarding=no", - "-o", "GatewayPorts=no", "-o", "X11Forwarding=no", "-o", "PermitTunnel=no", - "-o", "PermitUserEnvironment=no", "-o", "PermitUserRC=no", "-o", "PermitTTY=yes", - "-o", "MaxSessions=1", "-o", "UsePAM=no", - "-o", "AddressFamily=inet", "-o", "ListenAddress=0.0.0.0", "-o", "Port=22", - ] - self.sshd = subprocess.Popen(sshd_argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True) - time.sleep(0.05) - require(self.sshd.poll() is None, "unavailable", "workspace sshd failed to start") - if nfs_enabled: - try: - self._start_nfs(workspace_id) - except Exception: - self.sshd.terminate() - self.sshd.wait(timeout=2) - self.sshd = None - raise - self.connection_scope = scope - return self._connection_status(workspace_id, nfs_enabled) - - def _start_nfs(self, workspace_id: str) -> None: - try: - os.makedirs("/proc/fs/nfsd", mode=0o755, exist_ok=True) - for command in ("exportfs", "rpc.mountd", "rpc.nfsd"): - require(shutil.which(command) is not None, "unavailable", f"NFS server tool is unavailable: {command}") - if not os.path.ismount("/proc/fs/nfsd"): - subprocess.run(["mount", "-t", "nfsd", "nfsd", "/proc/fs/nfsd"], check=True, timeout=10) - options = "rw,fsid=0,sync,no_subtree_check,root_squash,all_squash,anonuid=1000,anongid=1000,insecure" - subprocess.run(["exportfs", "-i", "-o", options, "127.0.0.1:/workspace"], check=True, timeout=10) - self.mountd = subprocess.Popen(["rpc.mountd", "--foreground", "--no-udp", "--no-nfs-version", "2", "--no-nfs-version", "3", "--ttl", "10"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True) - time.sleep(0.05) - require(self.mountd.poll() is None, "unavailable", "NFS mount daemon failed to start") - subprocess.run(["rpc.nfsd", "--host", "127.0.0.1", "--no-udp", "--no-nfs-version", "2", "--no-nfs-version", "3", "--nfs-version", "4", "--leasetime", "10", "--grace-time", "10", "--port", "2049", "1"], check=True, timeout=10) - except ControlError: - self._stop_partial_nfs() - raise - except (OSError, subprocess.SubprocessError): - self._stop_partial_nfs() - raise ControlError("unavailable", "guest NFS service failed to start") from None - - def _stop_partial_nfs(self) -> None: - if self.mountd is not None and self.mountd.poll() is None: - self.mountd.terminate() - try: - self.mountd.wait(timeout=2) - except subprocess.TimeoutExpired: - self.mountd.kill() - self.mountd = None - if shutil.which("rpc.nfsd") is not None: - subprocess.run(["rpc.nfsd", "0"], check=False, timeout=5, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - if shutil.which("exportfs") is not None: - subprocess.run(["exportfs", "-u", "127.0.0.1:/workspace"], check=False, timeout=5, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - def _connection_status(self, workspace_id: str, nfs_enabled: bool) -> dict[str, Any]: - host_key = read_small_regular_file("/run/bloom/ssh/ssh_host_ed25519_key.pub", 1024) - require(host_key is not None and host_key.startswith("ssh-ed25519 "), "unavailable", "guest SSH host key is unavailable") - normalized_host_key = " ".join(host_key.split()[:2]) - return {"ssh": {"available": self.sshd is not None and self.sshd.poll() is None, "hostKey": normalized_host_key, "port": 22}, "nfs": {"available": nfs_enabled and self.mountd is not None and self.mountd.poll() is None, "port": 2049 if nfs_enabled else None}, "workspaceId": workspace_id} - - def close(self) -> None: - if self.sshd is not None and self.sshd.poll() is None: - self.sshd.terminate() - try: - self.sshd.wait(timeout=2) - except subprocess.TimeoutExpired: - self.sshd.kill() - if self.connection_scope is not None and self.connection_scope[2]: - try: - self._stop_partial_nfs() - except (OSError, subprocess.SubprocessError): - pass - - -def decode_frame(frame: bytes) -> Any: - require(len(frame) <= MAX_FRAME_BYTES, "limit_exceeded", "guest protocol frame is too large") - require(frame.endswith(b"\n"), "invalid_request", "guest protocol frame must end with a newline") - try: - return json.loads(frame[:-1].decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - raise ControlError("invalid_request", "guest protocol frame is not valid UTF-8 JSON") from None - - -def encode_response(response: dict[str, Any]) -> bytes: - frame = json.dumps(response, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + b"\n" - if len(frame) > MAX_FRAME_BYTES: - fallback = {"version": PROTOCOL_VERSION, "id": response.get("id", "invalid"), "ok": False, "error": {"code": "internal", "message": "guest response exceeded the frame limit"}} - return json.dumps(fallback, separators=(",", ":")).encode("utf-8") + b"\n" - return frame - - -def process_frame(control: GuestControl, frame: bytes) -> bytes: - try: - request = decode_frame(frame) - return encode_response(control.handle(request)) - except ControlError as error: - return encode_response({"version": PROTOCOL_VERSION, "id": "invalid", "ok": False, "error": {"code": error.code, "message": str(error)}}) - - -def serve_stdio(control: GuestControl, reader: BinaryIO, writer: BinaryIO) -> None: - while True: - frame = reader.readline(MAX_FRAME_BYTES + 2) - if not frame: - return - writer.write(process_frame(control, frame)) - writer.flush() - - -def create_unix_listener(path: str, uid: int, gid: int) -> socket.socket: - parent = os.path.dirname(path) - os.makedirs(parent, mode=0o755, exist_ok=True) - try: - metadata = os.lstat(path) - require(stat.S_ISSOCK(metadata.st_mode), "unavailable", "guest control socket path is occupied") - os.unlink(path) - except FileNotFoundError: - pass - listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - listener.bind(path) - os.chmod(path, 0o600) - if os.geteuid() == 0: - os.chown(path, uid, gid) - listener.listen(16) - return listener - - -def create_vsock_listener(port: int) -> socket.socket: - require(hasattr(socket, "AF_VSOCK"), "unavailable", "Python does not support AF_VSOCK") - listener = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) - listener.bind((getattr(socket, "VMADDR_CID_ANY", 0xFFFFFFFF), port)) - listener.listen(16) - return listener - - -def serve_sockets(control: GuestControl, listeners: list[socket.socket]) -> None: - while True: - readable, _, _ = select.select(listeners, [], []) - for listener in readable: - connection, _ = listener.accept() - with connection: - connection.settimeout(10) - frame = bytearray() - while len(frame) <= MAX_FRAME_BYTES: - chunk = connection.recv(min(64 * 1024, MAX_FRAME_BYTES + 1 - len(frame))) - if not chunk: - break - frame.extend(chunk) - if b"\n" in chunk: - break - connection.sendall(process_frame(control, bytes(frame))) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Bloom workspace guest control service") - parser.add_argument("--workspace", default="/workspace") - parser.add_argument("--workspace-quota-bytes", type=int, default=512 * 1024 * 1024) - parser.add_argument("--job-uid", type=int, default=1000) - parser.add_argument("--job-gid", type=int, default=1000) - parser.add_argument("--stdio", action="store_true") - parser.add_argument("--unix-socket") - parser.add_argument("--vsock-port", type=int) - args = parser.parse_args() - if not args.stdio and not args.unix_socket and args.vsock_port is None: - parser.error("at least one transport is required") - if not 1024 * 1024 <= args.workspace_quota_bytes <= 16 * 1024 * 1024 * 1024: - parser.error("workspace quota is outside the supported range") - if args.vsock_port is not None and not 1 <= args.vsock_port <= 0xFFFFFFFF: - parser.error("vsock port is outside the supported range") - return args - - -def handle_shutdown(_signum: int, _frame: Any) -> None: - raise KeyboardInterrupt - - -def main() -> int: - args = parse_args() - signal.signal(signal.SIGTERM, handle_shutdown) - for required_command in ("prlimit", "setpriv"): - if shutil.which(required_command) is None: - raise RuntimeError(f"required job isolation command is unavailable: {required_command}") - files = WorkspaceFiles(args.workspace, args.workspace_quota_bytes, args.job_uid, args.job_gid) - files.prepare_job_tmp() - jobs = JobEngine(files, args.job_uid, args.job_gid) - control = GuestControl(files, jobs) - listeners: list[socket.socket] = [] - try: - if args.unix_socket: - listeners.append(create_unix_listener(args.unix_socket, args.job_uid, args.job_gid)) - if args.vsock_port is not None: - listeners.append(create_vsock_listener(args.vsock_port)) - stdio_thread: threading.Thread | None = None - if args.stdio: - stdio_thread = threading.Thread( - target=serve_stdio, - args=(control, os.fdopen(os.dup(0), "rb", buffering=0), os.fdopen(os.dup(1), "wb", buffering=0)), - daemon=True, - name="guest-control-stdio", - ) - stdio_thread.start() - if listeners: - serve_sockets(control, listeners) - elif stdio_thread is not None: - while stdio_thread.is_alive(): - stdio_thread.join(0.25) - except KeyboardInterrupt: - pass - finally: - control.close() - jobs.close() - for listener in listeners: - listener.close() - if args.unix_socket: - try: - os.unlink(args.unix_socket) - except FileNotFoundError: - pass - files.close() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ops/guest-control/bloom-workspace b/ops/guest-control/bloom-workspace deleted file mode 100755 index 7a3e2e9..0000000 --- a/ops/guest-control/bloom-workspace +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 -"""Small guest-local client for the Bloom workspace control service.""" - -from __future__ import annotations - -import argparse -import base64 -import json -import os -import socket -import sys -import uuid - - -SOCKET_PATH = os.environ.get("BLOOM_GUEST_CONTROL_SOCKET", "/run/bloom/guest-control.sock") -MAX_FRAME_BYTES = 384 * 1024 -MAX_CHUNK_BYTES = 256 * 1024 - - -def request(operation: str, **fields: object) -> object: - message = {"version": 1, "id": uuid.uuid4().hex, "operation": operation, **fields} - frame = json.dumps(message, separators=(",", ":")).encode() + b"\n" - if len(frame) > MAX_FRAME_BYTES: - raise SystemExit("request exceeds the guest-control frame limit") - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: - client.connect(SOCKET_PATH) - client.sendall(frame) - response = bytearray() - while len(response) <= MAX_FRAME_BYTES: - chunk = client.recv(min(64 * 1024, MAX_FRAME_BYTES + 1 - len(response))) - if not chunk: - break - response.extend(chunk) - if b"\n" in chunk: - break - try: - decoded = json.loads(bytes(response).decode()) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise SystemExit(f"invalid guest-control response: {error}") from None - if not decoded.get("ok"): - detail = decoded.get("error", {}) - raise SystemExit(f"{detail.get('code', 'error')}: {detail.get('message', 'request failed')}") - return decoded.get("result") - - -def write_json(value: object) -> None: - json.dump(value, sys.stdout, indent=2, sort_keys=True) - sys.stdout.write("\n") - - -def parse_environment(values: list[str]) -> dict[str, str]: - result = {} - for value in values: - if "=" not in value: - raise SystemExit(f"environment must use NAME=VALUE: {value}") - name, item = value.split("=", 1) - result[name] = item - return result - - -def main() -> int: - parser = argparse.ArgumentParser(prog="bloom-workspace") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("status", help="show watch identity and available capabilities") - commands.add_parser("hello", help="show the protocol and enforced limits") - - files = commands.add_parser("files", help="use bounded workspace file operations").add_subparsers(dest="files_command", required=True) - files_list = files.add_parser("list") - files_list.add_argument("path") - files_get = files.add_parser("get") - files_get.add_argument("path") - files_get.add_argument("output") - files_put = files.add_parser("put") - files_put.add_argument("input") - files_put.add_argument("path") - files_delete = files.add_parser("delete") - files_delete.add_argument("path") - - jobs = commands.add_parser("jobs", help="start and inspect structured jobs").add_subparsers(dest="jobs_command", required=True) - jobs_start = jobs.add_parser("start") - jobs_start.add_argument("--cwd", default=".", help="relative directory below /workspace (default: root)") - jobs_start.add_argument("--timeout-ms", type=int, default=15 * 60 * 1000) - jobs_start.add_argument("--env", action="append", default=[]) - jobs_start.add_argument("argv", nargs=argparse.REMAINDER) - jobs_status = jobs.add_parser("status") - jobs_status.add_argument("job_id") - jobs_status.add_argument("--cursor", type=int, default=0) - jobs_cancel = jobs.add_parser("cancel") - jobs_cancel.add_argument("job_id") - - args = parser.parse_args() - - if args.command == "status": - write_json(request("bloom.status")) - elif args.command == "hello": - write_json(request("hello")) - elif args.command == "files": - if args.files_command == "list": - write_json(request("fs.list", path=args.path)) - elif args.files_command == "get": - offset = 0 - with open(args.output, "wb") as output: - while True: - result = request("fs.read", path=args.path, offset=offset, maxBytes=MAX_CHUNK_BYTES) - data = base64.b64decode(result["data"], validate=True) - output.write(data) - offset = result["nextOffset"] - if result["eof"]: - break - write_json({"path": args.path, "output": args.output, "size": offset}) - elif args.files_command == "put": - size = os.path.getsize(args.input) - if size > 8 * 1024 * 1024: - raise SystemExit("input exceeds the 8 MiB file limit") - offset = 0 - with open(args.input, "rb") as source: - while True: - chunk = source.read(MAX_CHUNK_BYTES) - if not chunk and offset: - break - result = request( - "fs.write", - path=args.path, - offset=offset, - data=base64.b64encode(chunk).decode("ascii"), - truncate=offset == 0, - ) - offset = result["nextOffset"] - if not chunk: - break - write_json({"path": args.path, "size": offset}) - elif args.files_command == "delete": - write_json(request("fs.delete", path=args.path, recursive=False)) - elif args.command == "jobs": - if args.jobs_command == "start": - argv = args.argv[1:] if args.argv[:1] == ["--"] else args.argv - if not argv: - raise SystemExit("jobs start requires an argv after --") - write_json( - request( - "job.start", - jobId=str(uuid.uuid4()), - argv=argv, - cwd=args.cwd, - environment=parse_environment(args.env), - timeoutMs=args.timeout_ms, - ) - ) - elif args.jobs_command == "status": - write_json(request("job.status", jobId=args.job_id, logOffset=args.cursor, maxBytes=MAX_CHUNK_BYTES)) - elif args.jobs_command == "cancel": - write_json(request("job.cancel", jobId=args.job_id)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ops/guest-control/src/client.rs b/ops/guest-control/src/client.rs new file mode 100644 index 0000000..ddb20f2 --- /dev/null +++ b/ops/guest-control/src/client.rs @@ -0,0 +1,417 @@ +//! Guest-local CLI client for the bloom-guest-control service. +//! +//! Connects to the Unix socket and provides `status`, `hello`, `files`, and +//! `jobs` subcommands — a direct port of the original Python `bloom-workspace`. + +use base64::Engine; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; + +const SOCKET_PATH: &str = "/run/bloom/guest-control.sock"; +const MAX_FRAME_BYTES: usize = 384 * 1024; +const MAX_CHUNK_BYTES: usize = 256 * 1024; + +fn get_socket_path() -> String { + std::env::var("BLOOM_GUEST_CONTROL_SOCKET").unwrap_or_else(|_| SOCKET_PATH.to_string()) +} + +fn send_request(operation: &str, fields: Value) -> Result { + let id = format!( + "{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let mut msg = json!({ "version": 1, "id": id, "operation": operation }); + if let Value::Object(ref mut map) = msg { + if let Value::Object(fields_map) = fields { + for (k, v) in fields_map { + map.insert(k, v); + } + } + } + let frame = serde_json::to_string(&msg).map_err(|e| format!("serialize error: {e}"))?; + let frame = frame + "\n"; + let frame_bytes = frame.as_bytes(); + if frame_bytes.len() > MAX_FRAME_BYTES { + return Err("request exceeds the guest-control frame limit".into()); + } + + let socket_path = get_socket_path(); + let mut client = UnixStream::connect(&socket_path) + .map_err(|e| format!("cannot connect to guest control socket: {e}"))?; + client + .write_all(frame_bytes) + .map_err(|e| format!("socket write failed: {e}"))?; + + let mut response = Vec::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = client + .read(&mut buf) + .map_err(|e| format!("socket read failed: {e}"))?; + if n == 0 { + break; + } + response.extend_from_slice(&buf[..n]); + if response.len() > MAX_FRAME_BYTES { + break; + } + if response.contains(&b'\n') { + break; + } + } + + let decoded: Value = serde_json::from_slice(&response) + .map_err(|e| format!("invalid guest-control response: {e}"))?; + if !decoded.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let code = decoded + .get("error") + .and_then(|e| e.get("code")) + .and_then(|c| c.as_str()) + .unwrap_or("error"); + let msg = decoded + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("request failed"); + return Err(format!("{code}: {msg}")); + } + Ok(decoded.get("result").cloned().unwrap_or(Value::Null)) +} + +fn print_json(value: &Value) { + let pretty = serde_json::to_string_pretty(value).unwrap_or_default(); + println!("{pretty}"); +} + +pub fn run(args: &[String]) -> i32 { + if args.is_empty() { + eprintln!("usage: bloom-workspace ..."); + return 1; + } + + let command = &args[0]; + let rest = &args[1..]; + + match command.as_str() { + "status" => match send_request("bloom.status", json!({})) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + }, + "hello" => match send_request("hello", json!({})) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + }, + "files" => { + if rest.is_empty() { + eprintln!("usage: bloom-workspace files ..."); + return 1; + } + let sub = &rest[0]; + let sub_args = &rest[1..]; + match sub.as_str() { + "list" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace files list "); + return 1; + } + match send_request("fs.list", json!({ "path": sub_args[0] })) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "get" => { + if sub_args.len() < 2 { + eprintln!("usage: bloom-workspace files get "); + return 1; + } + let path = &sub_args[0]; + let output = &sub_args[1]; + match download_file(path, output) { + Ok(()) => 0, + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "put" => { + if sub_args.len() < 2 { + eprintln!("usage: bloom-workspace files put "); + return 1; + } + let input = &sub_args[0]; + let path = &sub_args[1]; + match upload_file(input, path) { + Ok(()) => 0, + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "delete" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace files delete "); + return 1; + } + match send_request( + "fs.delete", + json!({ "path": sub_args[0], "recursive": false }), + ) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + _ => { + eprintln!("unknown files subcommand: {sub}"); + 1 + } + } + } + "jobs" => { + if rest.is_empty() { + eprintln!("usage: bloom-workspace jobs ..."); + return 1; + } + let sub = &rest[0]; + let sub_args = &rest[1..]; + match sub.as_str() { + "start" => { + // Parse flags: --cwd, --timeout-ms, --env, then -- argv + let mut cwd = ".".to_string(); + let mut timeout_ms: u64 = 15 * 60 * 1000; + let mut env: HashMap = HashMap::new(); + let mut argv: Vec = Vec::new(); + let mut i = 0; + while i < sub_args.len() { + match sub_args[i].as_str() { + "--cwd" => { + i += 1; + if i < sub_args.len() { + cwd = sub_args[i].clone(); + } + } + "--timeout-ms" => { + i += 1; + if i < sub_args.len() { + timeout_ms = sub_args[i].parse().unwrap_or(15 * 60 * 1000); + } + } + "--env" => { + i += 1; + if i < sub_args.len() { + if let Some((k, v)) = sub_args[i].split_once('=') { + env.insert(k.to_string(), v.to_string()); + } + } + } + "--" => { + i += 1; + while i < sub_args.len() { + argv.push(sub_args[i].clone()); + i += 1; + } + } + _ => { + argv.push(sub_args[i].clone()); + } + } + i += 1; + } + if argv.is_empty() { + eprintln!("jobs start requires an argv after --"); + return 1; + } + let job_id = format!( + "{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let env_json: Value = env + .into_iter() + .collect::>() + .into_iter() + .map(|(k, v)| (k, json!(v))) + .collect(); + match send_request( + "job.start", + json!({ + "jobId": job_id, + "argv": argv, + "cwd": cwd, + "environment": env_json, + "timeoutMs": timeout_ms, + }), + ) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "status" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace jobs status [--cursor N]"); + return 1; + } + let job_id = &sub_args[0]; + let mut cursor: u64 = 0; + let mut i = 1; + while i < sub_args.len() { + if sub_args[i] == "--cursor" { + i += 1; + if i < sub_args.len() { + cursor = sub_args[i].parse().unwrap_or(0); + } + } + i += 1; + } + match send_request( + "job.status", + json!({ + "jobId": job_id, + "logOffset": cursor, + "maxBytes": MAX_CHUNK_BYTES, + }), + ) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "cancel" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace jobs cancel "); + return 1; + } + match send_request("job.cancel", json!({ "jobId": sub_args[0] })) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + _ => { + eprintln!("unknown jobs subcommand: {sub}"); + 1 + } + } + } + _ => { + eprintln!("unknown command: {command}"); + 1 + } + } +} + +fn download_file(path: &str, output: &str) -> Result<(), String> { + let mut offset: u64 = 0; + let mut file = + std::fs::File::create(output).map_err(|e| format!("cannot create output file: {e}"))?; + loop { + let result = send_request( + "fs.read", + json!({ + "path": path, + "offset": offset, + "maxBytes": MAX_CHUNK_BYTES, + }), + )?; + let data_b64 = result.get("data").and_then(|d| d.as_str()).unwrap_or(""); + let data = base64::engine::general_purpose::STANDARD + .decode(data_b64) + .map_err(|e| format!("base64 decode error: {e}"))?; + file.write_all(&data) + .map_err(|e| format!("write error: {e}"))?; + offset = result + .get("nextOffset") + .and_then(|o| o.as_u64()) + .unwrap_or(offset + data.len() as u64); + if result.get("eof").and_then(|e| e.as_bool()).unwrap_or(true) { + break; + } + } + print_json(&json!({ "path": path, "output": output, "size": offset })); + Ok(()) +} + +fn upload_file(input: &str, path: &str) -> Result<(), String> { + let metadata = std::fs::metadata(input).map_err(|e| format!("cannot stat input: {e}"))?; + let size = metadata.len(); + if size > 8 * 1024 * 1024 { + return Err("input exceeds the 8 MiB file limit".into()); + } + let data = std::fs::read(input).map_err(|e| format!("cannot read input: {e}"))?; + let mut offset: u64 = 0; + let chunk_size = MAX_CHUNK_BYTES; + let mut first = true; + while offset < data.len() as u64 || first { + let end = ((offset as usize) + chunk_size).min(data.len()); + let chunk = &data[offset as usize..end]; + let encoded = base64::engine::general_purpose::STANDARD.encode(chunk); + let result = send_request( + "fs.write", + json!({ + "path": path, + "offset": offset, + "data": encoded, + "truncate": first, + }), + )?; + offset = result + .get("nextOffset") + .and_then(|o| o.as_u64()) + .unwrap_or(end as u64); + first = false; + if chunk.is_empty() { + break; + } + } + print_json(&json!({ "path": path, "size": offset })); + Ok(()) +} diff --git a/ops/guest-control/src/constants.rs b/ops/guest-control/src/constants.rs new file mode 100644 index 0000000..11baef9 --- /dev/null +++ b/ops/guest-control/src/constants.rs @@ -0,0 +1,53 @@ +//! Compile-time constants matching the Python implementation. + +pub const PROTOCOL_VERSION: u32 = 1; +pub const MAX_FRAME_BYTES: usize = 384 * 1024; +pub const MAX_FILE_CHUNK_BYTES: usize = 256 * 1024; +pub const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +pub const MAX_LOG_CHUNK_BYTES: usize = 256 * 1024; +pub const MAX_LOG_BYTES: usize = 1024 * 1024; +pub const MAX_LIST_ENTRIES: usize = 1000; +pub const MAX_SCAN_ENTRIES: usize = 20_000; +pub const MAX_ACTIVE_JOBS: usize = 2; +pub const MAX_RETAINED_JOBS: usize = 64; +pub const MAX_JOB_PROCESSES: u64 = 64; +pub const MAX_JOB_FILE_BYTES: u64 = 64 * 1024 * 1024; +pub const MAX_JOB_TIMEOUT_MS: u64 = 2 * 60 * 60 * 1000; +pub const JOB_KILL_GRACE_SECONDS: f64 = 1.0; +pub const BLOOM_SOCKET_PATH: &str = "/workspace/.bloom/run/bloom.sock"; +pub const MAX_OUTBOX_PLAN_BYTES: usize = 64 * 1024; +pub const MAX_OUTBOX_CHAINS: usize = 16; +pub const MAX_OUTBOX_PENDING: usize = 32; + +/// AF_VSOCK constant on Linux (40). +pub const AF_VSOCK: libc::c_int = 40; +/// VMADDR_CID_ANY +pub const VMADDR_CID_ANY: u32 = 0xFFFFFFFF; + +/// Environment variables passed through to jobs. +pub const USER_ENV_EXACT: &[&str] = &[ + "CI", + "DEBUG", + "FORCE_COLOR", + "LANG", + "LC_ALL", + "LOG_LEVEL", + "NODE_ENV", + "NO_COLOR", + "PYTHONUNBUFFERED", + "RUST_BACKTRACE", + "RUST_LOG", + "TERM", + "TZ", +]; + +pub const USER_ENV_PREFIXES: &[&str] = &["APP_", "JOB_", "TEST_"]; + +pub const SYSTEM_PROXY_ENV: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", +]; diff --git a/ops/guest-control/src/control.rs b/ops/guest-control/src/control.rs new file mode 100644 index 0000000..41b858d --- /dev/null +++ b/ops/guest-control/src/control.rs @@ -0,0 +1,849 @@ +//! Guest control dispatcher: routes operations, manages Bloom status and SSH/NFS connections. + +use crate::constants::*; +use crate::error::ControlError; +use crate::files::WorkspaceFiles; +use crate::jobs::JobEngine; +use crate::validate::*; +use base64::Engine; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +static SSH_CA_PUBLIC_KEY_RE: Lazy = + Lazy::new(|| Regex::new(r"^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$").unwrap()); + +struct ConnectionState { + sshd: Option, // pid + mountd: Option, // pid + scope: Option<(String, String, bool)>, // (workspace_id, wallet, nfs_enabled) +} + +pub struct GuestControl { + files: Arc, + jobs: Arc, + conn: Mutex, +} + +impl GuestControl { + pub fn new(files: Arc, jobs: Arc) -> Self { + Self { + files, + jobs, + conn: Mutex::new(ConnectionState { + sshd: None, + mountd: None, + scope: None, + }), + } + } + + pub fn close(&self) { + let mut conn = self.conn.lock().unwrap(); + if let Some(pid) = conn.sshd { + if unsafe { libc::kill(pid as i32, 0) } == 0 { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + // Wait briefly + for _ in 0..20 { + if unsafe { libc::kill(pid as i32, 0) } != 0 { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + } + } + conn.sshd = None; + if let Some(scope) = &conn.scope { + if scope.2 { + let _ = self.stop_partial_nfs(&mut conn); + } + } + } + + pub fn handle(&self, raw: &Value) -> Value { + let request_id = raw + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| REQUEST_ID.is_match(s)) + .unwrap_or("invalid") + .to_string(); + + let result = (|| -> Result { + validate_request(raw)?; + let operation = raw["operation"].as_str().unwrap_or(""); + match operation { + "hello" => Ok(json!({ + "protocolVersion": PROTOCOL_VERSION, + "operations": [ + "fs.list", "fs.read", "fs.write", "fs.delete", + "job.start", "job.status", "job.cancel", + "bloom.status", "connections.configure", "ceremony.pending" + ], + "limits": { + "fileChunkBytes": MAX_FILE_CHUNK_BYTES, + "fileBytes": MAX_FILE_BYTES, + "activeJobs": MAX_ACTIVE_JOBS, + "retainedJobs": MAX_RETAINED_JOBS, + "jobProcesses": MAX_JOB_PROCESSES, + "jobLogBytes": MAX_LOG_BYTES, + "jobTimeoutMs": MAX_JOB_TIMEOUT_MS, + } + })), + "fs.list" => self.files.list(&raw["path"]), + "fs.read" => self + .files + .read(&raw["path"], &raw["offset"], &raw["maxBytes"]), + "fs.write" => { + self.files + .write(&raw["path"], &raw["offset"], &raw["data"], &raw["truncate"]) + } + "fs.delete" => self.files.delete(&raw["path"], &raw["recursive"]), + "job.start" => { + // Clone the request to avoid lifetime issues + self.jobs.start(raw) + } + "job.status" => self.jobs.status(raw), + "job.cancel" => self.jobs.cancel(raw), + "bloom.status" => self.bloom_status(), + "connections.configure" => self.configure_connections(raw), + "ceremony.pending" => self.ceremony_pending(), + _ => Err(ControlError::invalid_request("unknown guest operation")), + } + })(); + + match result { + Ok(val) => { + json!({ "version": PROTOCOL_VERSION, "id": request_id, "ok": true, "result": val }) + } + Err(e) => { + json!({ "version": PROTOCOL_VERSION, "id": request_id, "ok": false, "error": { "code": e.code, "message": truncate_str(&e.message, 1024) } }) + } + } + } + + fn bloom_status(&self) -> Result { + let address = self.files.watch_identity(); + let has_addr = address.is_some(); + let executable = which("bloom").is_some(); + Ok(json!({ + "available": executable && has_addr, + "mount": { "path": "/bloom", "mounted": is_mount("/bloom") }, + "identity": if has_addr { + json!({ "kind": "watch", "address": address.unwrap() }) + } else { + Value::Null + }, + "capabilities": { + "files": true, + "jobs": true, + "bloomRead": executable && has_addr, + "walletSigning": false, + "transactions": false, + }, + "helper": { "name": "bloom-workspace", "protocolVersion": PROTOCOL_VERSION }, + })) + } + + fn bloom_ipc(&self, method: &str, params: &Value) -> Result { + if !Path::new(BLOOM_SOCKET_PATH).exists() { + return Err(ControlError::unavailable( + "Bloom IPC socket is not available", + )); + } + let request = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); + let frame = serde_json::to_string(&request).unwrap() + "\n"; + let mut client = UnixStream::connect(BLOOM_SOCKET_PATH) + .map_err(|_| ControlError::unavailable("cannot connect to Bloom IPC"))?; + client.set_read_timeout(Some(Duration::from_secs(10))).ok(); + client.set_write_timeout(Some(Duration::from_secs(10))).ok(); + client + .write_all(frame.as_bytes()) + .map_err(|_| ControlError::internal("bloom IPC write failed"))?; + let mut response = Vec::new(); + let mut buf = [0u8; 65536]; + loop { + let n = client + .read(&mut buf) + .map_err(|_| ControlError::internal("bloom IPC read failed"))?; + if n == 0 { + break; + } + response.extend_from_slice(&buf[..n]); + if response.contains(&b'\n') { + break; + } + if response.len() > 1024 * 1024 { + break; + } + } + let decoded: Value = serde_json::from_slice(&response) + .map_err(|_| ControlError::internal("bloom IPC returned invalid JSON"))?; + if decoded.get("error").is_some() { + return Err(ControlError::internal(format!( + "bloom IPC error: {}", + decoded["error"] + ))); + } + Ok(decoded.get("result").cloned().unwrap_or(Value::Null)) + } + + fn ceremony_pending(&self) -> Result { + let wallet_result = self.bloom_ipc("list", &json!({ "path": "/wallets" }))?; + let wallets: Vec = wallet_result + .get("entries") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("dir")) + .filter_map(|e| { + e.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + let mut pending = Vec::new(); + for wallet in wallets.iter().take(MAX_OUTBOX_CHAINS) { + let chain_result = self.bloom_ipc( + "list", + &json!({ "path": format!("/wallets/{}/chains", wallet) }), + )?; + let chains: Vec = chain_result + .get("entries") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("dir")) + .filter_map(|e| { + e.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + for chain in chains.iter().take(MAX_OUTBOX_CHAINS) { + let pending_result = self.bloom_ipc("list", &json!({ "path": format!("/wallets/{}/chains/{}/outbox/pending", wallet, chain) }))?; + let ids: Vec = pending_result + .get("entries") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("dir")) + .filter_map(|e| { + e.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + for tx_id in ids.iter().take(MAX_OUTBOX_PENDING) { + let plan_result = self.bloom_ipc("read", &json!({ "path": format!("/wallets/{}/chains/{}/outbox/pending/{}/plan.md", wallet, chain, tx_id) }))?; + let plan_b64 = plan_result + .get("bytes_b64") + .and_then(|b| b.as_str()) + .unwrap_or(""); + let plan_md = base64::engine::general_purpose::STANDARD + .decode(plan_b64) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + let plan_md = if plan_md.len() > MAX_OUTBOX_PLAN_BYTES { + plan_md[..MAX_OUTBOX_PLAN_BYTES].to_string() + } else { + plan_md + }; + + let ceremony_url = self.bloom_ipc("read", &json!({ "path": format!("/wallets/{}/chains/{}/outbox/pending/{}/approval_challenge.json", wallet, chain, tx_id) })) + .ok() + .and_then(|result| { + let b64 = result.get("bytes_b64").and_then(|b| b.as_str())?; + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + let json: Value = serde_json::from_slice(&bytes).ok()?; + json.get("ceremony_url").and_then(|u| u.as_str()).map(|s| s.to_string()) + }); + + pending.push(json!({ + "id": tx_id, + "chain": chain, + "wallet": wallet, + "planMd": plan_md, + "ceremonyUrl": ceremony_url, + })); + } + } + } + Ok(json!({ "requests": pending })) + } + + fn configure_connections(&self, request: &Value) -> Result { + let mut conn = self.conn.lock().unwrap(); + self.configure_connections_locked(&mut conn, request) + } + + fn configure_connections_locked( + &self, + conn: &mut ConnectionState, + request: &Value, + ) -> Result { + let workspace_id = request["workspaceId"] + .as_str() + .ok_or_else(|| ControlError::invalid_request("invalid workspace id"))?; + if !WORKSPACE_ID.is_match(workspace_id) { + return Err(ControlError::invalid_request("invalid workspace id")); + } + let wallet = request["wallet"] + .as_str() + .ok_or_else(|| ControlError::invalid_request("invalid workspace wallet"))?; + if !EVM_ADDRESS.is_match(wallet) { + return Err(ControlError::invalid_request("invalid workspace wallet")); + } + let ca_public_key = request["caPublicKey"] + .as_str() + .ok_or_else(|| ControlError::invalid_request("invalid SSH CA public key"))?; + if !SSH_CA_PUBLIC_KEY_RE.is_match(ca_public_key) { + return Err(ControlError::invalid_request("invalid SSH CA public key")); + } + let nfs_enabled = request["nfs"] + .as_bool() + .ok_or_else(|| ControlError::invalid_request("invalid NFS capability"))?; + + let scope = (workspace_id.to_string(), wallet.to_string(), nfs_enabled); + if let Some(existing) = &conn.scope { + if *existing == scope { + return Ok(self.connection_status(conn, workspace_id, nfs_enabled)); + } + return Err(ControlError::conflict( + "workspace connection scope is already configured", + )); + } + if unsafe { libc::geteuid() } != 0 { + return Err(ControlError::unavailable( + "workspace connections require the root guest controller", + )); + } + if which("sshd").is_none() || which("ssh-keygen").is_none() { + return Err(ControlError::unavailable( + "OpenSSH server tooling is unavailable", + )); + } + + // Validate CA public key payload + let parts: Vec<&str> = ca_public_key.splitn(2, ' ').collect(); + if parts.len() != 2 { + return Err(ControlError::invalid_request("invalid SSH CA public key")); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(parts[1]) + .map_err(|_| ControlError::invalid_request("invalid SSH CA public key"))?; + if decoded.len() < 32 || decoded.len() > 128 { + return Err(ControlError::invalid_request( + "invalid SSH CA public key payload", + )); + } + + let dir = "/run/bloom/ssh"; + std::fs::create_dir_all(dir) + .map_err(|_| ControlError::internal("cannot create ssh dir"))?; + set_mode(dir, 0o700); + + let ca_path = format!("{}/user_ca.pub", dir); + let principals_path = format!("{}/authorized_principals", dir); + let host_key_path = format!("{}/ssh_host_ed25519_key", dir); + + let owner_digest = sha256_hex(wallet.as_bytes()); + let owner_digest = &owner_digest[..32]; + let mut principals = vec![format!("bloom-shell-{}-w-{}", workspace_id, owner_digest)]; + if nfs_enabled { + principals.push(format!("bloom-nfs-{}-w-{}", workspace_id, owner_digest)); + } + + write_private_file(&ca_path, &format!("{}\n", ca_public_key), 0o600); + write_private_file( + &principals_path, + &format!("{}\n", principals.join("\n")), + 0o600, + ); + + if !Path::new(&host_key_path).exists() { + let status = std::process::Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-f", &host_key_path]) + .status(); + if !matches!(status, Ok(s) if s.success()) { + return Err(ControlError::unavailable("ssh-keygen failed")); + } + } + set_mode(&host_key_path, 0o600); + set_mode(&format!("{}.pub", host_key_path), 0o644); + + let shell = "/usr/local/libexec/bloom-workspace-shell"; + if !Path::new(shell).is_file() || is_symlink(shell) { + return Err(ControlError::unavailable( + "workspace SSH shell helper is unavailable", + )); + } + + let host_key_opt = format!("HostKey={}", host_key_path); + let ca_keys_opt = format!("TrustedUserCAKeys={}", ca_path); + let principals_opt = format!("AuthorizedPrincipalsFile={}", principals_path); + let sshd_argv: Vec<&str> = vec![ + "sshd", + "-D", + "-e", + "-f", + "/etc/ssh/sshd_config", + "-o", + &host_key_opt, + "-o", + &ca_keys_opt, + "-o", + &principals_opt, + "-o", + "AuthorizedKeysFile=none", + "-o", + "AuthenticationMethods=publickey", + "-o", + "PubkeyAuthentication=yes", + "-o", + "PasswordAuthentication=no", + "-o", + "KbdInteractiveAuthentication=no", + "-o", + "PermitRootLogin=no", + "-o", + "AllowUsers=workspace", + "-o", + "AllowAgentForwarding=no", + "-o", + "AllowTcpForwarding=local", + "-o", + "PermitOpen=127.0.0.1:2049 127.0.0.1:18734", + "-o", + "AllowStreamLocalForwarding=no", + "-o", + "GatewayPorts=no", + "-o", + "X11Forwarding=no", + "-o", + "PermitTunnel=no", + "-o", + "PermitUserEnvironment=no", + "-o", + "PermitUserRC=no", + "-o", + "PermitTTY=yes", + "-o", + "MaxSessions=1", + "-o", + "UsePAM=no", + "-o", + "AddressFamily=inet", + "-o", + "ListenAddress=0.0.0.0", + "-o", + "Port=22", + ]; + + let sshd_child = std::process::Command::new("sshd") + .args(&sshd_argv[1..]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|_| ControlError::unavailable("workspace sshd failed to start"))?; + std::thread::sleep(Duration::from_millis(50)); + let sshd_pid = sshd_child.id(); + + if nfs_enabled { + if let Err(e) = self.start_nfs(workspace_id) { + // Kill sshd + unsafe { + libc::kill(sshd_pid as i32, libc::SIGTERM); + } + std::thread::sleep(Duration::from_secs(2)); + unsafe { + libc::kill(sshd_pid as i32, libc::SIGKILL); + } + return Err(e); + } + conn.mountd = Some(0); // placeholder, set in start_nfs + } + + conn.sshd = Some(sshd_pid); + conn.scope = Some(scope); + Ok(self.connection_status(conn, workspace_id, nfs_enabled)) + } + + fn start_nfs(&self, workspace_id: &str) -> Result<(), ControlError> { + for cmd in &["exportfs", "rpc.mountd", "rpc.nfsd"] { + if which(cmd).is_none() { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable(&format!( + "NFS server tool is unavailable: {}", + cmd + ))); + } + } + std::fs::create_dir_all("/proc/fs/nfsd").ok(); + if !is_mount("/proc/fs/nfsd") { + let r = std::process::Command::new("mount") + .args(["-t", "nfsd", "nfsd", "/proc/fs/nfsd"]) + .status(); + if !matches!(r, Ok(s) if s.success()) { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("failed to mount nfsd filesystem")); + } + } + let options = "rw,fsid=0,sync,no_subtree_check,root_squash,all_squash,anonuid=1000,anongid=1000,insecure"; + let r = std::process::Command::new("exportfs") + .args(["-i", "-o", options, "127.0.0.1:/workspace"]) + .status(); + if !matches!(r, Ok(s) if s.success()) { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("exportfs failed")); + } + + let mountd_child = std::process::Command::new("rpc.mountd") + .args([ + "--foreground", + "--no-udp", + "--no-nfs-version", + "2", + "--no-nfs-version", + "3", + "--ttl", + "10", + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + match mountd_child { + Ok(child) => { + std::thread::sleep(Duration::from_millis(50)); + let mountd_pid = child.id(); + if unsafe { libc::kill(mountd_pid as i32, 0) } != 0 { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable( + "NFS mount daemon failed to start", + )); + } + { + let mut conn = self.conn.lock().unwrap(); + conn.mountd = Some(mountd_pid); + } + } + Err(_) => { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("rpc.mountd failed to start")); + } + } + + let r = std::process::Command::new("rpc.nfsd") + .args([ + "--host", + "127.0.0.1", + "--no-udp", + "--no-nfs-version", + "2", + "--no-nfs-version", + "3", + "--nfs-version", + "4", + "--leasetime", + "10", + "--grace-time", + "10", + "--port", + "2049", + "1", + ]) + .status(); + if !matches!(r, Ok(s) if s.success()) { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("rpc.nfsd failed to start")); + } + Ok(()) + } + + fn stop_partial_nfs(&self, conn: &mut ConnectionState) { + if let Some(pid) = conn.mountd { + if pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0 { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + for _ in 0..20 { + if unsafe { libc::kill(pid as i32, 0) } != 0 { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + } + } + conn.mountd = None; + if which("rpc.nfsd").is_some() { + let _ = std::process::Command::new("rpc.nfsd").arg("0").status(); + } + if which("exportfs").is_some() { + let _ = std::process::Command::new("exportfs") + .args(["-u", "127.0.0.1:/workspace"]) + .status(); + } + } + + fn connection_status( + &self, + conn: &ConnectionState, + workspace_id: &str, + nfs_enabled: bool, + ) -> Value { + let host_key = read_small_regular_file("/run/bloom/ssh/ssh_host_ed25519_key.pub", 1024); + let host_key = host_key + .filter(|h| h.starts_with("ssh-ed25519 ")) + .map(|h| h.split_whitespace().take(2).collect::>().join(" ")); + let host_key = match host_key { + Some(hk) => hk, + None => { + return json!({ "error": { "code": "unavailable", "message": "guest SSH host key is unavailable" } }) + } + }; + let ssh_available = conn + .sshd + .map(|p| unsafe { libc::kill(p as i32, 0) } == 0) + .unwrap_or(false); + let nfs_available = nfs_enabled + && conn + .mountd + .map(|p| p > 0 && unsafe { libc::kill(p as i32, 0) } == 0) + .unwrap_or(false); + json!({ + "ssh": { "available": ssh_available, "hostKey": host_key, "port": 22 }, + "nfs": { "available": nfs_available, "port": if nfs_enabled { Some(2049) } else { None } }, + "workspaceId": workspace_id, + }) + } +} + +// Helper functions + +fn truncate_str(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + s[..max].to_string() + } +} + +fn is_mount(path: &str) -> bool { + let c_path = std::ffi::CString::new(path).unwrap(); + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let mut parent_st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::stat(c_path.as_ptr(), &mut st) } != 0 { + return false; + } + let parent = Path::new(path).parent().unwrap_or(Path::new("/")); + let c_parent = std::ffi::CString::new(parent.to_string_lossy().as_bytes()).unwrap(); + if unsafe { libc::stat(c_parent.as_ptr(), &mut parent_st) } != 0 { + return false; + } + st.st_dev != parent_st.st_dev +} + +fn is_symlink(path: &str) -> bool { + std::fs::symlink_metadata(path) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) +} + +fn set_mode(path: &str, mode: libc::mode_t) { + let c_path = std::ffi::CString::new(path).unwrap(); + unsafe { + libc::chmod(c_path.as_ptr(), mode); + } +} + +fn write_private_file(path: &str, content: &str, mode: libc::mode_t) { + let c_path = std::ffi::CString::new(path).unwrap(); + let fd = unsafe { + libc::open( + c_path.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW, + mode, + ) + }; + if fd < 0 { + return; + } + unsafe { + libc::fchmod(fd, mode); + let bytes = content.as_bytes(); + let mut written: usize = 0; + while written < bytes.len() { + let n = libc::write( + fd, + bytes[written..].as_ptr() as *const _, + bytes.len() - written, + ); + if n <= 0 { + break; + } + written += n as usize; + } + libc::fsync(fd); + libc::close(fd); + } +} + +fn read_small_regular_file(path: &str, maximum: usize) -> Option { + let c_path = std::ffi::CString::new(path).ok()?; + let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_NOFOLLOW) }; + if fd < 0 { + return None; + } + let result = unsafe { + let mut st: libc::stat = std::mem::zeroed(); + if libc::fstat(fd, &mut st) != 0 { + return None; + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return None; + } + if st.st_size > maximum as i64 { + return None; + } + let mut buf = vec![0u8; maximum]; + let n = libc::read(fd, buf.as_mut_ptr() as *mut _, maximum); + libc::close(fd); + if n < 0 { + return None; + } + buf.truncate(n as usize); + String::from_utf8(buf).ok() + }; + result.map(|s| s.trim().to_string()) +} + +fn sha256_hex(data: &[u8]) -> String { + // Minimal SHA-256 implementation + let mut hash = [0u32; 8]; + hash[0] = 0x6a09e667; + hash[1] = 0xbb67ae85; + hash[2] = 0x3c6ef372; + hash[3] = 0xa54ff53a; + hash[4] = 0x510e527f; + hash[5] = 0x9b05688c; + hash[6] = 0x1f83d9ab; + hash[7] = 0x5be0cd19; + + let k: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + + // Pad message + let mut msg = data.to_vec(); + let bit_len = (msg.len() * 8) as u64; + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in msg.chunks(64) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let mut a = hash[0]; + let mut b = hash[1]; + let mut c = hash[2]; + let mut d = hash[3]; + let mut e = hash[4]; + let mut f = hash[5]; + let mut g = hash[6]; + let mut h = hash[7]; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(k[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + hash[0] = hash[0].wrapping_add(a); + hash[1] = hash[1].wrapping_add(b); + hash[2] = hash[2].wrapping_add(c); + hash[3] = hash[3].wrapping_add(d); + hash[4] = hash[4].wrapping_add(e); + hash[5] = hash[5].wrapping_add(f); + hash[6] = hash[6].wrapping_add(g); + hash[7] = hash[7].wrapping_add(h); + } + + let mut result = String::new(); + for h in &hash { + result.push_str(&format!("{:08x}", h)); + } + result +} + +fn which(cmd: &str) -> Option { + std::process::Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/ops/guest-control/src/error.rs b/ops/guest-control/src/error.rs new file mode 100644 index 0000000..1f3df64 --- /dev/null +++ b/ops/guest-control/src/error.rs @@ -0,0 +1,59 @@ +//! Error types matching the Python ControlError. + +use std::fmt; + +#[derive(Debug, Clone)] +pub struct ControlError { + pub code: String, + pub message: String, +} + +impl ControlError { + pub fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } + + pub fn invalid_request(msg: impl Into) -> Self { + Self::new("invalid_request", msg) + } + pub fn not_found(msg: impl Into) -> Self { + Self::new("not_found", msg) + } + pub fn permission_denied(msg: impl Into) -> Self { + Self::new("permission_denied", msg) + } + pub fn limit_exceeded(msg: impl Into) -> Self { + Self::new("limit_exceeded", msg) + } + pub fn conflict(msg: impl Into) -> Self { + Self::new("conflict", msg) + } + pub fn unavailable(msg: impl Into) -> Self { + Self::new("unavailable", msg) + } + pub fn internal(msg: impl Into) -> Self { + Self::new("internal", msg) + } +} + +impl fmt::Display for ControlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ControlError {} + +/// Check an errno and map to appropriate ControlError. +pub fn from_io_error(error: &std::io::Error) -> ControlError { + let raw = error.raw_os_error().unwrap_or(0); + let code = match raw { + libc::ENOENT => "not_found", + libc::EACCES | libc::EPERM | libc::ELOOP | libc::ENOTDIR => "permission_denied", + _ => "internal", + }; + ControlError::new(code, "workspace operation failed") +} diff --git a/ops/guest-control/src/files.rs b/ops/guest-control/src/files.rs new file mode 100644 index 0000000..bcf9d08 --- /dev/null +++ b/ops/guest-control/src/files.rs @@ -0,0 +1,642 @@ +//! Workspace file operations with path-traversal-safe directory walking. + +use crate::constants::*; +use crate::error::{from_io_error, ControlError}; +use crate::validate::{validate_relative_path, validate_workspace_directory}; +use base64::Engine; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; +use std::os::unix::io::RawFd; +use std::path::Path; +use std::sync::Mutex; + +static EVM_ADDRESS_RE: Lazy = Lazy::new(|| Regex::new(r"^0x[0-9a-f]{40}$").unwrap()); + +pub struct WorkspaceFiles { + root: std::path::PathBuf, + root_fd: RawFd, + quota_bytes: u64, + owner_uid: u32, + owner_gid: u32, + write_lock: Mutex<()>, + _root_file: std::fs::File, // keeps root_fd alive +} + +impl WorkspaceFiles { + pub fn new( + root: &Path, + quota_bytes: u64, + owner_uid: u32, + owner_gid: u32, + ) -> Result { + let canonical = root + .canonicalize() + .map_err(|e| format!("workspace root does not exist: {e}"))?; + let meta = std::fs::symlink_metadata(&canonical) + .map_err(|e| format!("cannot stat workspace root: {e}"))?; + let mode = meta.permissions().mode(); + if mode & libc::S_IFMT as u32 == libc::S_IFLNK as u32 { + return Err("workspace root must not be a symlink".into()); + } + if !canonical.is_dir() { + return Err("workspace root must be a directory".into()); + } + use std::os::unix::fs::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) + .open(&canonical) + .map_err(|e| format!("unsafe or unavailable workspace root: {e}"))?; + + let root_fd = file.as_raw_fd(); + Ok(Self { + root: canonical, + root_fd, + quota_bytes, + owner_uid, + owner_gid, + write_lock: Mutex::new(()), + _root_file: file, + }) + } + + pub fn close(&self) { + // root_fd is closed when _root_file is dropped + } + + pub fn prepare_job_tmp(&self) -> Result<(), ControlError> { + let fd = self.open_directory(&[".tmp".to_string()], true)?; + unsafe { + libc::fchmod(fd, 0o700); + if unsafe { libc::geteuid() } == 0 { + unsafe { + libc::fchown(fd, self.owner_uid, self.owner_gid); + } + } + libc::close(fd); + } + Ok(()) + } + + fn open_directory(&self, parts: &[String], create: bool) -> Result { + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW; + // Use openat(".", root_fd) instead of dup(root_fd) so each call gets an + // independent directory handle with its own read position. dup() shares + // the open file description, so readdir in one consumer advances the + // position for all copies. + let c_dot = std::ffi::CString::new(".").unwrap(); + let mut current = unsafe { libc::openat(self.root_fd, c_dot.as_ptr(), flags) }; + if current < 0 { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + for part in parts { + let c_part = std::ffi::CString::new(part.as_str()).unwrap(); + if create { + unsafe { + if libc::mkdirat(current, c_part.as_ptr(), 0o700) == 0 { + if unsafe { libc::geteuid() } == 0 { + libc::fchownat( + current, + c_part.as_ptr(), + self.owner_uid, + self.owner_gid, + libc::AT_SYMLINK_NOFOLLOW, + ); + } + } + } + } + let next = unsafe { libc::openat(current, c_part.as_ptr(), flags) }; + if next < 0 { + unsafe { + libc::close(current); + } + return Err(from_io_error(&std::io::Error::last_os_error())); + } + unsafe { + libc::close(current); + } + current = next; + } + Ok(current) + } + + fn open_file( + &self, + parts: &[String], + flags: i32, + mode: u32, + create_parents: bool, + ) -> Result { + if parts.len() < 2 { + return Err(ControlError::invalid_request( + "file path must include a parent directory", + )); + } + let parent = self.open_directory(&parts[..parts.len() - 1], create_parents)?; + let c_name = std::ffi::CString::new(parts[parts.len() - 1].as_str()).unwrap(); + let fd = unsafe { + libc::openat( + parent, + c_name.as_ptr(), + flags | libc::O_NOFOLLOW, + mode as libc::c_uint, + ) + }; + unsafe { + libc::close(parent); + } + if fd < 0 { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + Ok(fd) + } + + pub fn list(&self, path: &Value) -> Result { + let parts = validate_workspace_directory(path)?; + let dir = self.open_directory(&parts, false)?; + let c_dot = std::ffi::CString::new(".").unwrap(); + let result = (|| -> Result { + let mut entries = vec![]; + unsafe { + let dirp = libc::fdopendir(libc::openat( + dir, + c_dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY, + )); + if dirp.is_null() { + return Err(ControlError::internal("fdopendir failed")); + } + loop { + let entry_ptr = libc::readdir(dirp); + if entry_ptr.is_null() { + break; + } + let name = std::ffi::CStr::from_ptr((*entry_ptr).d_name.as_ptr()) + .to_string_lossy() + .to_string(); + if name == "." || name == ".." { + continue; + } + entries.push(name); + } + libc::closedir(dirp); + } + entries.sort(); + if entries.len() > MAX_LIST_ENTRIES { + return Err(ControlError::limit_exceeded( + "directory contains too many entries", + )); + } + let mut result_entries = Vec::with_capacity(entries.len()); + for name in &entries { + let c_name = std::ffi::CString::new(name.as_str()).unwrap(); + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let rc = unsafe { + libc::fstatat(dir, c_name.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) + }; + if rc != 0 { + continue; + } + let kind = if (st.st_mode & libc::S_IFMT as libc::mode_t) + == libc::S_IFLNK as libc::mode_t + { + "symlink" + } else if (st.st_mode & libc::S_IFMT as libc::mode_t) + == libc::S_IFDIR as libc::mode_t + { + "directory" + } else { + "file" + }; + let size = if (st.st_mode & libc::S_IFMT as libc::mode_t) + == libc::S_IFREG as libc::mode_t + { + st.st_size as u64 + } else { + 0 + }; + let mut path_str = parts.join("/"); + if !path_str.is_empty() { + path_str.push('/'); + } + path_str.push_str(name); + result_entries.push(json!({ + "path": path_str, + "type": kind, + "size": size, + "modifiedAt": (st.st_mtime as u64) * 1000, + })); + } + Ok(json!({ "files": result_entries })) + })(); + unsafe { + libc::close(dir); + } + result + } + + pub fn read( + &self, + path: &Value, + offset: &Value, + max_bytes: &Value, + ) -> Result { + let parts = validate_relative_path(path)?; + let start = + crate::validate::validate_integer(offset, 0, MAX_FILE_BYTES as i64, "file offset")? + as i64; + let limit = crate::validate::validate_integer( + max_bytes, + 1, + MAX_FILE_CHUNK_BYTES as i64, + "file read size", + )? as usize; + let fd = self.open_file(&parts, libc::O_RDONLY, 0, false)?; + let result = (|| -> Result { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return Err(ControlError::internal("fstat failed")); + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return Err(ControlError::invalid_request( + "download path is not a regular file", + )); + } + if st.st_size > MAX_FILE_BYTES as i64 { + return Err(ControlError::limit_exceeded( + "file exceeds the download limit", + )); + } + if start > st.st_size { + return Err(ControlError::invalid_request( + "file offset exceeds the file size", + )); + } + let mut buf = vec![0u8; limit]; + let n = unsafe { libc::pread(fd, buf.as_mut_ptr() as *mut _, limit, start as i64) }; + if n < 0 { + return Err(ControlError::internal("pread failed")); + } + buf.truncate(n as usize); + let b64 = base64::engine::general_purpose::STANDARD.encode(&buf); + let next = start + n as i64; + Ok(json!({ + "path": parts.join("/"), + "offset": start, + "nextOffset": next, + "size": st.st_size, + "eof": next >= st.st_size, + "data": b64, + })) + })(); + unsafe { + libc::close(fd); + } + result + } + + pub fn write( + &self, + path: &Value, + offset: &Value, + encoded: &Value, + truncate: &Value, + ) -> Result { + let parts = validate_relative_path(path)?; + let start = + crate::validate::validate_integer(offset, 0, MAX_FILE_BYTES as i64, "file offset")? + as i64; + let encoded_str = encoded + .as_str() + .ok_or_else(|| ControlError::invalid_request("file payload must be base64"))?; + let do_truncate = truncate + .as_bool() + .ok_or_else(|| ControlError::invalid_request("truncate must be boolean"))?; + if do_truncate && start != 0 { + return Err(ControlError::invalid_request( + "a truncating write must begin at offset zero", + )); + } + let chunk = base64::engine::general_purpose::STANDARD + .decode(encoded_str) + .map_err(|_| ControlError::invalid_request("file payload is not canonical base64"))?; + // Verify canonical encoding + let re_encoded = base64::engine::general_purpose::STANDARD.encode(&chunk); + if re_encoded != encoded_str { + return Err(ControlError::invalid_request( + "file payload is not canonical base64", + )); + } + if chunk.len() > MAX_FILE_CHUNK_BYTES { + return Err(ControlError::limit_exceeded( + "file write chunk is too large", + )); + } + if start + chunk.len() as i64 > MAX_FILE_BYTES as i64 { + return Err(ControlError::limit_exceeded( + "file exceeds the upload limit", + )); + } + let _guard = self.write_lock.lock().unwrap(); + let flags = libc::O_WRONLY | libc::O_CREAT; + let fd = self.open_file(&parts, flags, 0o600, true)?; + let result = (|| -> Result { + if unsafe { libc::geteuid() } == 0 { + unsafe { + libc::fchown(fd, self.owner_uid, self.owner_gid); + } + } + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return Err(ControlError::internal("fstat failed")); + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return Err(ControlError::invalid_request( + "upload target is not a regular file", + )); + } + let prior = st.st_size; + let used = self.directory_bytes()?; + let resulting_size = if do_truncate { + (start + chunk.len() as i64).max(0) + } else { + (start + chunk.len() as i64).max(prior) + }; + if used - prior + resulting_size > self.quota_bytes as i64 { + return Err(ControlError::limit_exceeded( + "workspace storage quota exceeded", + )); + } + if do_truncate { + unsafe { + libc::ftruncate(fd, 0); + } + } + let mut written: usize = 0; + while written < chunk.len() { + let n = unsafe { + libc::pwrite( + fd, + chunk[written as usize..].as_ptr() as *const _, + (chunk.len() - written), + (start + written as i64) as i64, + ) + }; + if n <= 0 { + return Err(ControlError::internal("file write made no progress")); + } + written += n as usize; + } + unsafe { + libc::fsync(fd); + } + let mut st2: libc::stat = unsafe { std::mem::zeroed() }; + unsafe { + libc::fstat(fd, &mut st2); + } + Ok(json!({ + "path": parts.join("/"), + "size": st2.st_size, + "nextOffset": start + chunk.len() as i64, + "usedBytes": used - prior + resulting_size, + "quotaBytes": self.quota_bytes, + })) + })(); + unsafe { + libc::close(fd); + } + result + } + + pub fn delete(&self, path: &Value, recursive: &Value) -> Result { + let parts = validate_relative_path(path)?; + // recursive must be false (protocol only allows non-recursive single-file delete) + let do_recursive = recursive.as_bool().unwrap_or(false); + if do_recursive { + return Err(ControlError::permission_denied( + "recursive deletion is not supported", + )); + } + let _guard = self.write_lock.lock().unwrap(); + let parent = self.open_directory(&parts[..parts.len() - 1], false)?; + let result = (|| -> Result { + let c_name = std::ffi::CString::new(parts[parts.len() - 1].as_str()).unwrap(); + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstatat(parent, c_name.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) } + != 0 + { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return Err(ControlError::invalid_request( + "delete path is not a regular file", + )); + } + if unsafe { libc::unlinkat(parent, c_name.as_ptr(), 0) } != 0 { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + let used_after = self.directory_bytes()?; + Ok(json!({ + "path": parts.join("/"), + "deleted": true, + "usedBytes": used_after, + "quotaBytes": self.quota_bytes, + })) + })(); + unsafe { + libc::close(parent); + } + result + } + + pub fn open_job_cwd(&self, path: &Value) -> Result { + let parts = validate_workspace_directory(path)?; + self.open_directory(&parts, false) + } + + pub fn read_small(&self, path: &str, maximum: usize) -> Option { + let path_val = serde_json::Value::String(path.to_string()); + let parts = validate_relative_path(&path_val).ok()?; + let fd = self.open_file(&parts, libc::O_RDONLY, 0, false).ok()?; + let result = (|| -> Option { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return None; + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return None; + } + if st.st_size > maximum as i64 { + return None; + } + let mut buf = vec![0u8; maximum]; + let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, maximum) }; + if n < 0 { + return None; + } + buf.truncate(n as usize); + String::from_utf8(buf).ok().map(|s| s.trim().to_string()) + })(); + unsafe { + libc::close(fd); + } + result + } + + pub fn watch_identity(&self) -> Option { + let keystore_fd = self + .open_directory(&[".bloom".to_string(), "keystore".to_string()], false) + .ok()?; + let identity = (|| -> Option { + // Check keystore has exactly "workspace-login" + let mut entries = list_dir_entries(keystore_fd)?; + entries.sort(); + if entries != vec!["workspace-login".to_string()] { + return None; + } + unsafe { + libc::close(keystore_fd); + } + + // Check wallet has exactly address, kind, pubkey + let wallet_fd = self + .open_directory( + &[ + ".bloom".to_string(), + "keystore".to_string(), + "workspace-login".to_string(), + ], + false, + ) + .ok()?; + let mut wallet_entries = list_dir_entries(wallet_fd)?; + wallet_entries.sort(); + if wallet_entries + != vec![ + "address".to_string(), + "kind".to_string(), + "pubkey".to_string(), + ] + { + return None; + } + unsafe { + libc::close(wallet_fd); + } + + let kind = self.read_small(".bloom/keystore/workspace-login/kind", 256)?; + let address = self.read_small(".bloom/keystore/workspace-login/address", 256)?; + let public_key = self.read_small(".bloom/keystore/workspace-login/pubkey", 256)?; + let normalized = address.to_lowercase(); + if kind != "watch" || public_key != "" || !EVM_ADDRESS_RE.is_match(&normalized) { + return None; + } + Some(normalized) + })(); + // keystore_fd may already be closed inside the closure + identity + } + + fn directory_bytes(&self) -> Result { + let mut count = 0i64; + let mut total = 0i64; + walk_dir_bytes(self.root_fd, &mut count, &mut total, 0)?; + Ok(total) + } +} + +fn list_dir_entries(fd: RawFd) -> Option> { + let mut entries = vec![]; + let c_dot = std::ffi::CString::new(".").unwrap(); + unsafe { + let dirp = libc::fdopendir(libc::openat( + fd, + c_dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY, + )); + if dirp.is_null() { + return None; + } + loop { + let entry_ptr = libc::readdir(dirp); + if entry_ptr.is_null() { + break; + } + let name = std::ffi::CStr::from_ptr((*entry_ptr).d_name.as_ptr()) + .to_string_lossy() + .to_string(); + if name == "." || name == ".." { + continue; + } + entries.push(name); + } + libc::closedir(dirp); + } + Some(entries) +} + +fn walk_dir_bytes( + fd: RawFd, + count: &mut i64, + total: &mut i64, + depth: usize, +) -> Result<(), ControlError> { + if depth > 32 { + return Ok(()); + } + let c_dot = std::ffi::CString::new(".").unwrap(); + unsafe { + let dirp = libc::fdopendir(libc::openat( + fd, + c_dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY, + )); + if dirp.is_null() { + return Err(ControlError::internal("fdopendir failed")); + } + loop { + let entry_ptr = libc::readdir(dirp); + if entry_ptr.is_null() { + break; + } + let name = std::ffi::CStr::from_ptr((*entry_ptr).d_name.as_ptr()) + .to_string_lossy() + .to_string(); + if name == "." || name == ".." { + continue; + } + let c_name = std::ffi::CString::new(name.as_str()).unwrap(); + let mut st: libc::stat = std::mem::zeroed(); + if libc::fstatat(fd, c_name.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) != 0 { + continue; + } + let mode = st.st_mode & libc::S_IFMT as libc::mode_t; + if mode == libc::S_IFREG as libc::mode_t { + *count += 1; + if *count > MAX_SCAN_ENTRIES as i64 { + libc::closedir(dirp); + return Err(ControlError::limit_exceeded( + "workspace contains too many files", + )); + } + *total += st.st_size; + } else if mode == libc::S_IFDIR as libc::mode_t { + let child = libc::openat( + fd, + c_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ); + if child >= 0 { + let _ = walk_dir_bytes(child, count, total, depth + 1); + libc::close(child); + } + } + } + libc::closedir(dirp); + } + Ok(()) +} diff --git a/ops/guest-control/src/jobs.rs b/ops/guest-control/src/jobs.rs new file mode 100644 index 0000000..dc73b01 --- /dev/null +++ b/ops/guest-control/src/jobs.rs @@ -0,0 +1,610 @@ +//! Job engine: spawn isolated processes with prlimit + setpriv. + +use crate::constants::*; +use crate::error::ControlError; +use crate::files::WorkspaceFiles; +use crate::logs::LogRing; +use crate::validate::{ + validate_environment, validate_integer, validate_job_id, validate_workspace_directory, +}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io::Read; +use std::os::unix::io::AsRawFd; +use std::process::Stdio; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +const TERMINAL_STATES: &[&str] = &["succeeded", "failed", "cancelled", "timed_out"]; + +fn is_terminal(state: &str) -> bool { + TERMINAL_STATES.contains(&state) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +struct Job { + job_id: String, + argv: Vec, + cwd: String, + timeout_ms: u64, + created_at: u64, + state: String, + started_at: Option, + finished_at: Option, + exit_code: Option, + signal_number: Option, + pid: Option, + cancel_requested: bool, + logs: Arc, + finished: Arc, +} + +impl Job { + fn new(job_id: String, argv: Vec, cwd: String, timeout_ms: u64) -> Self { + Self { + job_id, + argv, + cwd, + timeout_ms, + created_at: now_ms(), + state: "queued".into(), + started_at: None, + finished_at: None, + exit_code: None, + signal_number: None, + pid: None, + cancel_requested: false, + logs: Arc::new(LogRing::new()), + finished: Arc::new(AtomicBool::new(false)), + } + } +} + +pub struct JobEngine { + files: Arc, + job_uid: u32, + job_gid: u32, + jobs: Arc>>>>, + order: Arc>>, +} + +impl JobEngine { + pub fn new(files: Arc, job_uid: u32, job_gid: u32) -> Self { + Self { + files, + job_uid, + job_gid, + jobs: Arc::new(Mutex::new(HashMap::new())), + order: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn start(&self, request: &Value) -> Result { + let job_id = validate_job_id(&request["jobId"])?; + let argv_val = &request["argv"]; + let argv_arr = argv_val + .as_array() + .ok_or_else(|| ControlError::invalid_request("argv must be an array"))?; + if argv_arr.is_empty() || argv_arr.len() > 64 { + return Err(ControlError::invalid_request( + "argv must contain between 1 and 64 arguments", + )); + } + let mut validated_argv = Vec::with_capacity(argv_arr.len()); + let mut total_argv = 0usize; + for arg in argv_arr { + let s = arg.as_str().ok_or_else(|| { + ControlError::invalid_request("argv contains an invalid argument") + })?; + if s.is_empty() || s.contains('\0') { + return Err(ControlError::invalid_request( + "argv contains an invalid argument", + )); + } + let size = s.len(); + if size > 4096 { + return Err(ControlError::limit_exceeded( + "argv contains an oversized argument", + )); + } + total_argv += size; + if total_argv > 32 * 1024 { + return Err(ControlError::limit_exceeded( + "argv exceeds the aggregate size limit", + )); + } + validated_argv.push(s.to_string()); + } + let cwd_parts = validate_workspace_directory(&request["cwd"])?; + let cwd = if cwd_parts.is_empty() { + ".".to_string() + } else { + cwd_parts.join("/") + }; + let timeout_ms = validate_integer( + &request["timeoutMs"], + 1000, + MAX_JOB_TIMEOUT_MS as i64, + "job timeout", + )? as u64; + let user_env = validate_environment(&request["environment"])?; + + // Check limits before inserting + { + let jobs = self.jobs.lock().unwrap(); + if jobs.contains_key(&job_id) { + return Err(ControlError::conflict("job id already exists")); + } + let active = jobs + .values() + .filter(|j| { + let job = j.lock().unwrap(); + !is_terminal(&job.state) + }) + .count(); + if active >= MAX_ACTIVE_JOBS { + return Err(ControlError::limit_exceeded( + "workspace already has the maximum number of active jobs", + )); + } + } + + // Prune terminal jobs + self.prune_terminal_jobs(); + + { + let jobs = self.jobs.lock().unwrap(); + if jobs.len() >= MAX_RETAINED_JOBS { + return Err(ControlError::limit_exceeded( + "workspace has too many retained jobs", + )); + } + } + + let cwd_fd = self.files.open_job_cwd(&Value::String(cwd.clone()))?; + let job = Arc::new(Mutex::new(Job::new( + job_id.clone(), + validated_argv.clone(), + cwd.clone(), + timeout_ms, + ))); + let launcher = self.launcher_argv(&validated_argv); + + // Build environment + let env = self.job_environment(&user_env); + + // Spawn process + let cwd_path = format!("/proc/self/fd/{}", cwd_fd); + + let mut command = std::process::Command::new(&launcher[0]); + command.args(&launcher[1..]); + command.current_dir(&cwd_path); + command.env_clear(); + for (k, v) in &env { + command.env(k, v); + } + command.stdin(std::process::Stdio::null()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + // Keep cwd_fd alive across exec + use std::os::unix::process::CommandExt; + let cwd_fd_for_pre_exec = cwd_fd; + unsafe { + command.pre_exec(move || { + // Clear CLOEXEC so cwd_fd survives exec + let flags = libc::fcntl(cwd_fd_for_pre_exec, libc::F_GETFD); + if flags >= 0 { + libc::fcntl( + cwd_fd_for_pre_exec, + libc::F_SETFD, + flags & !libc::FD_CLOEXEC, + ); + } + // Create new session for process-group isolation + libc::setsid(); + Ok(()) + }); + } + + let child = match command.spawn() { + Ok(c) => c, + Err(e) => { + unsafe { + libc::close(cwd_fd); + } + { + let mut job_guard = job.lock().unwrap(); + job_guard.state = "failed".into(); + job_guard.finished_at = Some(now_ms()); + let msg = format!("bloom job launch failed: {e}\n"); + job_guard.logs.append(msg.as_bytes()); + job_guard.finished.store(true, Ordering::SeqCst); + } + let status = self.status_of(&job, 0, MAX_LOG_CHUNK_BYTES); + // Insert the failed job so status() can find it + self.jobs + .lock() + .unwrap() + .insert(job_id.clone(), job.clone()); + self.order.lock().unwrap().push(job_id.clone()); + return Ok(status); + } + }; + + let pid = child.id(); + let mut stdout = child.stdout; + let mut stderr = child.stderr; + + { + let mut job_guard = job.lock().unwrap(); + job_guard.started_at = Some(now_ms()); + job_guard.state = "running".into(); + job_guard.pid = Some(pid); + } + + // Insert into table + self.jobs + .lock() + .unwrap() + .insert(job_id.clone(), job.clone()); + self.order.lock().unwrap().push(job_id.clone()); + + // Spawn concurrent log-streaming + wait thread. + // The stdout reader runs concurrently with the waitpid poller so that + // logs appear in real-time (job.status callers can see output while the + // job is still running). + let logs_for_capture = job.lock().unwrap().logs.clone(); + let job_for_wait = job.clone(); + let timeout = timeout_ms; + std::thread::spawn(move || { + // Take ownership of stdout so we can read from it + let mut stdout = stdout; + + // Set both pipes to non-blocking + if let Some(ref s) = stdout { + let fd = s.as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + if let Some(ref s) = stderr { + let fd = s.as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout); + let mut timed_out = false; + let mut status: libc::c_int = 0; + let mut reaped = false; + + // Helper to drain a non-blocking pipe into the log buffer + fn drain_nonblocking(pipe: &mut Option, logs: &LogRing) { + if let Some(ref mut out) = pipe { + let mut buf = [0u8; 16 * 1024]; + loop { + match out.read(&mut buf) { + Ok(0) => break, + Ok(n) => logs.append(&buf[..n]), + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => break, + } + } + } + } + fn drain_blocking(pipe: &mut Option, logs: &LogRing) { + if let Some(ref mut out) = pipe { + let mut buf = [0u8; 16 * 1024]; + loop { + match out.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => logs.append(&buf[..n]), + } + } + } + } + + loop { + // Drain any available stdout and stderr (non-blocking) + drain_nonblocking(&mut stdout, &logs_for_capture); + drain_nonblocking(&mut stderr, &logs_for_capture); + + // Check if child exited + if !reaped { + let rc = unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }; + if rc == pid as i32 { + reaped = true; + } else if rc < 0 { + reaped = true; + status = 0; + } + } + + if reaped { + // Final drain of stdout and stderr + drain_blocking(&mut stdout, &logs_for_capture); + drain_blocking(&mut stderr, &logs_for_capture); + break; + } + + // Check timeout + if std::time::Instant::now() >= deadline { + timed_out = true; + signal_group(pid, libc::SIGTERM); + // Wait briefly for graceful exit + for _ in 0..100 { + let rc = unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }; + if rc == pid as i32 || rc < 0 { + reaped = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if !reaped { + // Force kill + signal_group(pid, libc::SIGKILL); + unsafe { + libc::waitpid(pid as i32, &mut status, 0); + } + reaped = true; + } + // Final drain after timeout kill + drain_blocking(&mut stdout, &logs_for_capture); + drain_blocking(&mut stderr, &logs_for_capture); + break; + } + + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + // Determine terminal state + let cancel_requested = job_for_wait.lock().unwrap().cancel_requested; + let (state, exit_code, signal) = if timed_out && !cancel_requested { + ("timed_out".into(), None, None) + } else if cancel_requested { + ("cancelled".into(), None, None) + } else if libc::WIFEXITED(status) { + let code = libc::WEXITSTATUS(status); + if code == 0 { + ("succeeded".into(), Some(code), None) + } else { + ("failed".into(), Some(code), None) + } + } else if libc::WIFSIGNALED(status) { + ("failed".into(), None, Some(libc::WTERMSIG(status))) + } else { + ("failed".into(), None, None) + }; + + let mut job_guard = job_for_wait.lock().unwrap(); + job_guard.finished_at = Some(now_ms()); + job_guard.state = state; + if let Some(sig) = signal { + job_guard.signal_number = Some(sig); + } else { + job_guard.exit_code = exit_code; + } + job_guard.finished.store(true, Ordering::SeqCst); + }); + + let status = self.status_of(&job, 0, MAX_LOG_CHUNK_BYTES); + unsafe { + libc::close(cwd_fd); + } + Ok(status) + } + + pub fn status(&self, request: &Value) -> Result { + let job_id = validate_job_id(&request["jobId"])?; + let log_offset = + validate_integer(&request["logOffset"], 0, 2i64.pow(53) - 1, "log cursor")? as u64; + let max_bytes = validate_integer( + &request["maxBytes"], + 1, + MAX_LOG_CHUNK_BYTES as i64, + "log read size", + )? as usize; + let job = { + let jobs = self.jobs.lock().unwrap(); + jobs.get(&job_id) + .cloned() + .ok_or_else(|| ControlError::not_found("job does not exist"))? + }; + Ok(self.status_of(&job, log_offset, max_bytes)) + } + + pub fn cancel(&self, request: &Value) -> Result { + let job_id = validate_job_id(&request["jobId"])?; + let job = { + let jobs = self.jobs.lock().unwrap(); + jobs.get(&job_id) + .cloned() + .ok_or_else(|| ControlError::not_found("job does not exist"))? + }; + let mut job_guard = job.lock().unwrap(); + if is_terminal(&job_guard.state) { + return Ok(self.status_of_inner(&job_guard, job_guard.logs.end_offset(), 1)); + } + let pid = match job_guard.pid { + Some(p) => p, + None => return Err(ControlError::conflict("job has not started")), + }; + let first_request = !job_guard.cancel_requested; + job_guard.cancel_requested = true; + // SIGTERM the process group + signal_group(pid, libc::SIGTERM); + if first_request { + let job_for_cancel = job.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs_f64(JOB_KILL_GRACE_SECONDS)); + let guard = job_for_cancel.lock().unwrap(); + if let Some(pid) = guard.pid { + signal_group(pid, libc::SIGKILL); + } + }); + } + Ok(self.status_of_inner(&job_guard, job_guard.logs.end_offset(), 1)) + } + + pub fn close(&self) { + let jobs = self.jobs.lock().unwrap(); + for job_arc in jobs.values() { + let job_guard = job_arc.lock().unwrap(); + if !is_terminal(&job_guard.state) { + if let Some(pid) = job_guard.pid { + signal_group(pid, libc::SIGKILL); + } + } + } + } + + fn status_of(&self, job: &Arc>, log_offset: u64, max_bytes: usize) -> Value { + let job_guard = job.lock().unwrap(); + self.status_of_inner(&job_guard, log_offset, max_bytes) + } + + fn status_of_inner(&self, job: &Job, log_offset: u64, max_bytes: usize) -> Value { + let logs_val = job + .logs + .slice(log_offset, max_bytes, is_terminal(&job.state)) + .unwrap_or_else(|e| json!({"error": e.message})); + json!({ + "jobId": job.job_id, + "state": job.state, + "createdAt": job.created_at, + "startedAt": job.started_at, + "finishedAt": job.finished_at, + "exitCode": job.exit_code, + "signal": job.signal_number, + "timeoutMs": job.timeout_ms, + "logs": logs_val, + }) + } + + fn prune_terminal_jobs(&self) { + let mut jobs = self.jobs.lock().unwrap(); + let mut order = self.order.lock().unwrap(); + while jobs.len() >= MAX_RETAINED_JOBS { + let terminal_id = order + .iter() + .find(|id| { + if let Some(j) = jobs.get(*id) { + let g = j.lock().unwrap(); + is_terminal(&g.state) + } else { + false + } + }) + .cloned(); + match terminal_id { + Some(id) => { + jobs.remove(&id); + order.retain(|x| *x != id); + } + None => break, + } + } + } + + fn launcher_argv(&self, user_argv: &[String]) -> Vec { + let prlimit = which("prlimit").unwrap_or_else(|| "/usr/bin/prlimit".into()); + let setpriv = which("setpriv").unwrap_or_else(|| "/usr/bin/setpriv".into()); + let mut cmd = vec![ + prlimit, + "--nofile=64:64".into(), + format!("--fsize={}:{}", MAX_JOB_FILE_BYTES, MAX_JOB_FILE_BYTES), + "--core=0:0".into(), + ]; + if unsafe { libc::geteuid() } == 0 { + cmd.push(format!( + "--nproc={}:{}", + MAX_JOB_PROCESSES, MAX_JOB_PROCESSES + )); + } + cmd.push("--".into()); + cmd.push(setpriv); + cmd.push("--no-new-privs".into()); + cmd.push("--pdeathsig=SIGKILL".into()); + if unsafe { libc::geteuid() } == 0 { + cmd.extend([ + "--bounding-set=-all".into(), + "--inh-caps=-all".into(), + "--ambient-caps=-all".into(), + format!("--reuid={}", self.job_uid), + format!("--regid={}", self.job_gid), + "--clear-groups".into(), + ]); + } + cmd.push("--".into()); + cmd.extend(user_argv.iter().cloned()); + cmd + } + + fn job_environment(&self, user_env: &BTreeMap) -> BTreeMap { + let mut env = BTreeMap::new(); + env.insert("HOME".into(), "/workspace".into()); + env.insert("USER".into(), "workspace".into()); + env.insert("LOGNAME".into(), "workspace".into()); + env.insert("SHELL".into(), "/bin/bash".into()); + // Inherit PATH from parent so test/toolchain binaries are discoverable. + // In production, the guest image sets PATH appropriately. + let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); + env.insert("PATH".into(), path); + env.insert("TMPDIR".into(), "/workspace/.tmp".into()); + env.insert("LANG".into(), "C.UTF-8".into()); + + for name in SYSTEM_PROXY_ENV { + if let Ok(value) = std::env::var(name) { + if !value.is_empty() + && value.len() <= 2048 + && !value.contains('@') + && !value.contains('\0') + { + env.insert(name.to_string(), value); + } + } + } + for name in &["SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"] { + if let Ok(value) = std::env::var(name) { + if value.starts_with('/') && !value.contains('\0') && value.len() <= 1024 { + env.insert(name.to_string(), value); + } + } + } + for (k, v) in user_env { + env.insert(k.clone(), v.clone()); + } + env + } +} + +fn which(cmd: &str) -> Option { + std::process::Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} + +fn signal_group(pid: u32, sig: libc::c_int) { + // killpg expects a negative pid for process group + unsafe { + // The pid is already a session leader (we called setsid in pre_exec) + // So killpg(pid) = kill(-pid, sig) + libc::kill(-(pid as i32), sig); + } +} diff --git a/ops/guest-control/src/logs.rs b/ops/guest-control/src/logs.rs new file mode 100644 index 0000000..7f40397 --- /dev/null +++ b/ops/guest-control/src/logs.rs @@ -0,0 +1,76 @@ +//! Bounded log ring buffer with absolute offset tracking. + +use crate::constants::*; +use crate::error::ControlError; +use base64::Engine; +use serde_json::{json, Value}; +use std::sync::Mutex; + +pub struct LogRing { + data: Mutex, +} + +struct LogInner { + buf: Vec, + start_offset: u64, + end_offset: u64, +} + +impl LogRing { + pub fn new() -> Self { + Self { + data: Mutex::new(LogInner { + buf: Vec::new(), + start_offset: 0, + end_offset: 0, + }), + } + } + + pub fn append(&self, chunk: &[u8]) { + if chunk.is_empty() { + return; + } + let mut inner = self.data.lock().unwrap(); + inner.buf.extend_from_slice(chunk); + inner.end_offset += chunk.len() as u64; + let overflow = inner.buf.len() as i64 - MAX_LOG_BYTES as i64; + if overflow > 0 { + inner.buf.drain(..overflow as usize); + inner.start_offset += overflow as u64; + } + } + + pub fn slice( + &self, + requested_offset: u64, + maximum: usize, + terminal: bool, + ) -> Result { + let inner = self.data.lock().unwrap(); + if requested_offset > inner.end_offset { + return Err(ControlError::invalid_request( + "log cursor is beyond the current log end", + )); + } + let offset = requested_offset.max(inner.start_offset); + let relative = (offset - inner.start_offset) as usize; + let end = (relative + maximum).min(inner.buf.len()); + let chunk = &inner.buf[relative..end]; + let next_offset = offset + chunk.len() as u64; + let b64 = base64::engine::general_purpose::STANDARD.encode(chunk); + Ok(json!({ + "offset": offset, + "nextOffset": next_offset, + "endOffset": inner.end_offset, + "truncatedBefore": requested_offset < inner.start_offset, + "eof": terminal && next_offset == inner.end_offset, + "encoding": "base64", + "data": b64, + })) + } + + pub fn end_offset(&self) -> u64 { + self.data.lock().unwrap().end_offset + } +} diff --git a/ops/guest-control/src/main.rs b/ops/guest-control/src/main.rs new file mode 100644 index 0000000..eeee3db --- /dev/null +++ b/ops/guest-control/src/main.rs @@ -0,0 +1,153 @@ +//! Bounded guest-side file, job, and Bloom status service. +//! +//! Accepts the version-1 JSON-line protocol over AF_VSOCK, a guest-local Unix +//! socket, and/or stdio. Every job is exec'd via prlimit + setpriv as the +//! unprivileged workspace account with no capabilities and no-new-privileges. +//! +//! This binary is intentionally dependency-light. It speaks the same wire +//! protocol as the original Python implementation and is a drop-in replacement. + +mod client; +mod constants; +mod control; +mod error; +mod files; +mod jobs; +mod logs; +mod protocol; +mod server; +mod validate; + +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::atomic::AtomicBool; + +/// Global shutdown flag set by SIGTERM/SIGINT signal handlers. +pub static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); + +fn main() -> ExitCode { + // If the first argument is a known client subcommand, dispatch to the CLI client. + let argv: Vec = std::env::args().skip(1).collect(); + match argv.first().map(|s| s.as_str()) { + Some("status") | Some("hello") | Some("files") | Some("jobs") => { + return ExitCode::from(client::run(&argv) as u8); + } + _ => {} + } + + // SIGTERM/SIGINT → graceful shutdown via atomic flag + unsafe { + extern "C" fn handle_signal(_sig: libc::c_int) { + SHOULD_EXIT.store(true, std::sync::atomic::Ordering::SeqCst); + } + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = handle_signal as *const () as usize; + libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut()); + libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut()); + } + + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(2); + } + }; + + server::run(args) +} + +pub struct Args { + pub workspace: PathBuf, + pub workspace_quota_bytes: u64, + pub job_uid: u32, + pub job_gid: u32, + pub stdio: bool, + pub unix_socket: Option, + pub vsock_port: Option, +} + +fn parse_args() -> Result { + let mut workspace = PathBuf::from("/workspace"); + let mut workspace_quota_bytes: u64 = 512 * 1024 * 1024; + let mut job_uid: u32 = 1000; + let mut job_gid: u32 = 1000; + let mut stdio = false; + let mut unix_socket: Option = None; + let mut vsock_port: Option = None; + + let mut args = std::env::args_os().skip(1); + while let Some(arg) = args.next() { + let arg = arg.to_string_lossy().to_string(); + match arg.as_str() { + "--stdio" => stdio = true, + "--workspace" => { + workspace = PathBuf::from(args.next().ok_or("--workspace requires a value")?); + } + "--workspace-quota-bytes" => { + let v = args + .next() + .ok_or("--workspace-quota-bytes requires a value")?; + workspace_quota_bytes = v + .to_string_lossy() + .parse() + .map_err(|_| "workspace-quota-bytes must be a number")?; + } + "--job-uid" => { + let v = args.next().ok_or("--job-uid requires a value")?; + job_uid = v + .to_string_lossy() + .parse() + .map_err(|_| "job-uid must be a number")?; + } + "--job-gid" => { + let v = args.next().ok_or("--job-gid requires a value")?; + job_gid = v + .to_string_lossy() + .parse() + .map_err(|_| "job-gid must be a number")?; + } + "--unix-socket" => { + unix_socket = Some(PathBuf::from( + args.next().ok_or("--unix-socket requires a value")?, + )); + } + "--vsock-port" => { + let v = args.next().ok_or("--vsock-port requires a value")?; + vsock_port = Some( + v.to_string_lossy() + .parse() + .map_err(|_| "vsock-port must be a number")?, + ); + } + _ => return Err(format!("unknown argument: {arg}")), + } + } + + if !stdio && unix_socket.is_none() && vsock_port.is_none() { + return Err( + "at least one transport is required (--stdio, --unix-socket, or --vsock-port)".into(), + ); + } + if !(1024 * 1024..=16 * 1024 * 1024 * 1024).contains(&workspace_quota_bytes) { + return Err("workspace quota is outside the supported range".into()); + } + if let Some(port) = vsock_port { + if port == 0 || port == u32::MAX { + return Err("vsock port is outside the supported range".into()); + } + } + + Ok(Args { + workspace, + workspace_quota_bytes, + job_uid, + job_gid, + stdio, + unix_socket, + vsock_port, + }) +} + +// Re-export for binary-level checks +pub use constants::*; diff --git a/ops/guest-control/src/protocol.rs b/ops/guest-control/src/protocol.rs new file mode 100644 index 0000000..3d45ef0 --- /dev/null +++ b/ops/guest-control/src/protocol.rs @@ -0,0 +1,50 @@ +//! JSON-line protocol frame encoding and decoding. + +use crate::constants::*; +use crate::error::ControlError; +use serde_json::Value; + +pub fn decode_frame(frame: &[u8]) -> Result { + if frame.len() > MAX_FRAME_BYTES { + return Err(ControlError::limit_exceeded( + "guest protocol frame is too large", + )); + } + if !frame.ends_with(b"\n") { + return Err(ControlError::invalid_request( + "guest protocol frame must end with a newline", + )); + } + let trimmed = &frame[..frame.len().saturating_sub(1)]; + serde_json::from_slice(trimmed) + .map_err(|_| ControlError::invalid_request("guest protocol frame is not valid UTF-8 JSON")) +} + +pub fn encode_response(response: &Value) -> Vec { + let mut frame = serde_json::to_vec(response).unwrap_or_default(); + frame.push(b'\n'); + if frame.len() > MAX_FRAME_BYTES { + let fallback = serde_json::json!({ + "version": PROTOCOL_VERSION, + "id": response.get("id").and_then(|v| v.as_str()).unwrap_or("invalid"), + "ok": false, + "error": { "code": "internal", "message": "guest response exceeded the frame limit" } + }); + frame = serde_json::to_vec(&fallback).unwrap_or_default(); + frame.push(b'\n'); + } + frame +} + +pub fn process_frame(control: &crate::control::GuestControl, frame: &[u8]) -> Vec { + let response = match decode_frame(frame) { + Ok(request) => control.handle(&request), + Err(e) => serde_json::json!({ + "version": PROTOCOL_VERSION, + "id": "invalid", + "ok": false, + "error": { "code": e.code, "message": e.message } + }), + }; + encode_response(&response) +} diff --git a/ops/guest-control/src/server.rs b/ops/guest-control/src/server.rs new file mode 100644 index 0000000..89ee9f8 --- /dev/null +++ b/ops/guest-control/src/server.rs @@ -0,0 +1,349 @@ +//! Transport server: stdio, Unix socket, and AF_VSOCK listeners. + +use crate::constants::*; +use crate::control::GuestControl; +use crate::files::WorkspaceFiles; +use crate::jobs::JobEngine; +use crate::protocol::process_frame; +use crate::Args; +use crate::SHOULD_EXIT; +use std::io::{BufRead, Write}; +use std::os::fd::RawFd; +use std::os::unix::fs::FileTypeExt; +use std::process::ExitCode; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +pub fn run(args: Args) -> ExitCode { + let files = match WorkspaceFiles::new( + &args.workspace, + args.workspace_quota_bytes, + args.job_uid, + args.job_gid, + ) { + Ok(f) => Arc::new(f), + Err(e) => { + eprintln!("fatal: {e}"); + return ExitCode::from(1); + } + }; + + // Verify prlimit and setpriv exist + if which("prlimit").is_none() || which("setpriv").is_none() { + eprintln!("required job isolation command is unavailable"); + return ExitCode::from(1); + } + + if let Err(e) = files.prepare_job_tmp() { + eprintln!("failed to prepare job tmp: {e}"); + return ExitCode::from(1); + } + + let jobs = Arc::new(JobEngine::new(files.clone(), args.job_uid, args.job_gid)); + let control = Arc::new(GuestControl::new(files.clone(), jobs.clone())); + + let mut listeners: Vec = Vec::new(); + + // Unix socket listener + if let Some(ref sock_path) = args.unix_socket { + match create_unix_listener(sock_path, args.job_uid, args.job_gid) { + Ok(fd) => listeners.push(ListenFd::Unix(fd)), + Err(e) => { + eprintln!("failed to create unix socket: {e}"); + control.close(); + jobs.close(); + files.close(); + return ExitCode::from(1); + } + } + } + + // VSOCK listener + if let Some(port) = args.vsock_port { + match create_vsock_listener(port) { + Ok(fd) => listeners.push(ListenFd::Vsock(fd)), + Err(e) => { + eprintln!("failed to create vsock listener: {e}"); + control.close(); + jobs.close(); + files.close(); + return ExitCode::from(1); + } + } + } + + // Stdio transport + if args.stdio { + let control_clone = control.clone(); + std::thread::spawn(move || { + serve_stdio(control_clone); + // Signal main thread to exit when stdin closes + SHOULD_EXIT.store(true, Ordering::SeqCst); + }); + } + + // Socket transport + if !listeners.is_empty() { + serve_sockets(control.clone(), &listeners); + } else { + // Stdio-only mode: wait for shutdown signal + while !SHOULD_EXIT.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + + // Cleanup + control.close(); + jobs.close(); + files.close(); + + if let Some(ref sock_path) = args.unix_socket { + let _ = std::fs::remove_file(sock_path); + } + + ExitCode::SUCCESS +} + +enum ListenFd { + Unix(RawFd), + Vsock(RawFd), +} + +impl ListenFd { + fn raw(&self) -> RawFd { + match self { + Self::Unix(fd) | Self::Vsock(fd) => *fd, + } + } +} + +fn serve_stdio(control: Arc) { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut reader = stdin.lock(); + let mut writer = stdout.lock(); + let mut buf = String::new(); + loop { + buf.clear(); + match reader.read_line(&mut buf) { + Ok(0) | Err(_) => return, + Ok(_) => { + if buf.len() > MAX_FRAME_BYTES + 2 { + let err = serde_json::json!({ + "version": PROTOCOL_VERSION, + "id": "invalid", + "ok": false, + "error": { "code": "limit_exceeded", "message": "guest protocol frame is too large" } + }); + let resp = crate::protocol::encode_response(&err); + let _ = writer.write_all(&resp); + let _ = writer.flush(); + continue; + } + let response = process_frame(&control, buf.as_bytes()); + let _ = writer.write_all(&response); + let _ = writer.flush(); + } + } + } +} + +fn serve_sockets(control: Arc, listeners: &[ListenFd]) { + loop { + if SHOULD_EXIT.load(Ordering::SeqCst) { + return; + } + let mut fds: Vec = listeners + .iter() + .map(|l| libc::pollfd { + fd: l.raw(), + events: libc::POLLIN, + revents: 0, + }) + .collect(); + + // 500ms timeout so we can check SHOULD_EXIT periodically + let rc = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, 500) }; + if rc < 0 { + if unsafe { *libc::__errno_location() } == libc::EINTR { + continue; + } + return; + } + if rc == 0 { + continue; // timeout — check SHOULD_EXIT at top of loop + } + + for (i, fd) in fds.iter().enumerate() { + if fd.revents & libc::POLLIN == 0 { + continue; + } + let listen_fd = listeners[i].raw(); + let addr_storage = match &listeners[i] { + ListenFd::Unix(_) => { + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let conn = + unsafe { libc::accept(listen_fd, &mut addr as *mut _ as *mut _, &mut len) }; + if conn < 0 { + continue; + } + Some(conn) + } + ListenFd::Vsock(_) => { + let mut addr: libc::sockaddr = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let conn = unsafe { libc::accept(listen_fd, &mut addr, &mut len) }; + if conn < 0 { + continue; + } + Some(conn) + } + }; + + if let Some(conn) = addr_storage { + handle_socket_connection(&control, conn); + } + } + } +} + +fn handle_socket_connection(control: &GuestControl, fd: RawFd) { + // Set 10s timeout + let tv = libc::timeval { + tv_sec: 10, + tv_usec: 0, + }; + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + &tv as *const _ as *const _, + std::mem::size_of_val(&tv) as libc::socklen_t, + ); + } + + let mut frame = Vec::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }; + if n <= 0 { + break; + } + frame.extend_from_slice(&buf[..n as usize]); + if frame.len() > MAX_FRAME_BYTES { + break; + } + if frame.contains(&b'\n') { + break; + } + } + + let response = process_frame(control, &frame); + unsafe { + let mut written: usize = 0; + while written < response.len() { + let n = libc::write( + fd, + response[written..].as_ptr() as *const _, + response.len() - written, + ); + if n <= 0 { + break; + } + written += n as usize; + } + libc::close(fd); + } +} + +fn create_unix_listener(path: &std::path::Path, uid: u32, gid: u32) -> Result { + let parent = path.parent().ok_or("invalid socket path")?; + std::fs::create_dir_all(parent).map_err(|e| format!("cannot create socket dir: {e}"))?; + + // Remove existing socket + if path.exists() { + let meta = + std::fs::symlink_metadata(path).map_err(|e| format!("cannot stat socket: {e}"))?; + if !meta.file_type().is_socket() && !meta.file_type().is_symlink() { + return Err("guest control socket path is occupied".into()); + } + std::fs::remove_file(path).ok(); + } + + let c_path = std::ffi::CString::new(path.to_string_lossy().as_bytes()) + .map_err(|_| "invalid path".to_string())?; + let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err("socket() failed".into()); + } + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + addr.sun_family = libc::AF_UNIX as _; + let path_str = path.to_string_lossy().into_owned(); + let path_bytes = path_str.as_bytes(); + if path_bytes.len() >= addr.sun_path.len() { + return Err("socket path too long".into()); + } + for (i, &b) in path_bytes.iter().enumerate() { + addr.sun_path[i] = b as _; + } + let addr_len = (2 + path_bytes.len()) as libc::socklen_t; + if unsafe { libc::bind(fd, &addr as *const _ as *const _, addr_len) } < 0 { + unsafe { + libc::close(fd); + } + return Err("bind() failed".into()); + } + unsafe { + libc::chmod(c_path.as_ptr(), 0o600); + if libc::geteuid() == 0 { + libc::chown(c_path.as_ptr(), uid, gid); + } + libc::listen(fd, 16); + } + Ok(fd) +} + +fn create_vsock_listener(port: u32) -> Result { + // AF_VSOCK = 40 on Linux + let fd = unsafe { libc::socket(AF_VSOCK, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err("vsock socket() failed — AF_VSOCK not supported".into()); + } + let mut addr: libc::sockaddr = unsafe { std::mem::zeroed() }; + addr.sa_family = AF_VSOCK as _; + // sockaddr_vm layout: family(2), reserved(2), cid(4), port(4) = 12 bytes + let addr_ptr = &addr as *const _ as *const u8; + // cid = VMADDR_CID_ANY at offset 4 + unsafe { + let cid_ptr = addr_ptr.add(4) as *mut u32; + *cid_ptr = VMADDR_CID_ANY; + let port_ptr = addr_ptr.add(8) as *mut u32; + *port_ptr = port; + } + let addr_len = 12; // sizeof(sockaddr_vm) + if unsafe { libc::bind(fd, &addr, addr_len) } < 0 { + unsafe { + libc::close(fd); + } + return Err("vsock bind() failed".into()); + } + if unsafe { libc::listen(fd, 16) } < 0 { + unsafe { + libc::close(fd); + } + return Err("vsock listen() failed".into()); + } + Ok(fd) +} + +fn which(cmd: &str) -> Option { + std::process::Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/ops/guest-control/src/validate.rs b/ops/guest-control/src/validate.rs new file mode 100644 index 0000000..258ee73 --- /dev/null +++ b/ops/guest-control/src/validate.rs @@ -0,0 +1,251 @@ +//! Request validation — mirrors the Python validate_* functions exactly. + +use crate::constants::*; +use crate::error::ControlError; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::Value; + +pub static REQUEST_ID: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_-]{1,64}$").unwrap()); +pub static ENV_NAME: Lazy = + Lazy::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$").unwrap()); +pub static EVM_ADDRESS: Lazy = Lazy::new(|| Regex::new(r"^0x[0-9a-f]{40}$").unwrap()); +pub static SSH_CA_PUBLIC_KEY: Lazy = + Lazy::new(|| Regex::new(r"^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$").unwrap()); +pub static WORKSPACE_ID: Lazy = Lazy::new(|| { + Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$").unwrap() +}); + +/// Operation → expected field set. +pub fn expected_fields(operation: &str) -> Option> { + let base = vec!["version", "id", "operation"]; + let mut fields = match operation { + "hello" => base, + "fs.list" => { + let mut v = base; + v.push("path"); + v + } + "fs.read" => { + let mut v = base; + v.extend_from_slice(&["path", "offset", "maxBytes"]); + v + } + "fs.write" => { + let mut v = base; + v.extend_from_slice(&["path", "offset", "data", "truncate"]); + v + } + "fs.delete" => { + let mut v = base; + v.extend_from_slice(&["path", "recursive"]); + v + } + "job.start" => { + let mut v = base; + v.extend_from_slice(&["jobId", "argv", "cwd", "environment", "timeoutMs"]); + v + } + "job.status" => { + let mut v = base; + v.extend_from_slice(&["jobId", "logOffset", "maxBytes"]); + v + } + "job.cancel" => { + let mut v = base; + v.push("jobId"); + v + } + "bloom.status" => base, + "connections.configure" => { + let mut v = base; + v.extend_from_slice(&["workspaceId", "wallet", "caPublicKey", "nfs"]); + v + } + "ceremony.pending" => base, + _ => return None, + }; + fields.sort(); + Some(fields) +} + +pub fn validate_request(raw: &Value) -> Result<(), ControlError> { + let obj = raw + .as_object() + .ok_or_else(|| ControlError::invalid_request("request must be an object"))?; + let version = obj.get("version").and_then(|v| v.as_u64()).unwrap_or(0); + if version != PROTOCOL_VERSION as u64 { + return Err(ControlError::invalid_request( + "unsupported guest protocol version", + )); + } + let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if !REQUEST_ID.is_match(id) { + return Err(ControlError::invalid_request("invalid request id")); + } + let operation = obj.get("operation").and_then(|v| v.as_str()).unwrap_or(""); + if operation.is_empty() { + return Err(ControlError::invalid_request("operation is required")); + } + let expected = expected_fields(operation) + .ok_or_else(|| ControlError::invalid_request("unknown guest operation"))?; + let mut actual: Vec<&str> = obj.keys().map(|s| s.as_str()).collect(); + actual.sort(); + if actual != expected { + return Err(ControlError::invalid_request( + "request fields do not match the operation contract", + )); + } + Ok(()) +} + +/// Validate and split a relative workspace path. Returns the path components. +pub fn validate_relative_path(value: &Value) -> Result, ControlError> { + let s = value + .as_str() + .ok_or_else(|| ControlError::invalid_request("workspace path must be a string"))?; + let byte_len = s.len(); + if byte_len == 0 || byte_len > 1024 { + return Err(ControlError::invalid_request( + "workspace path has an invalid length", + )); + } + if s.contains('\0') || s.contains('\\') { + return Err(ControlError::invalid_request( + "workspace path contains forbidden characters", + )); + } + if s.starts_with('/') || s.ends_with('/') { + return Err(ControlError::invalid_request( + "workspace path must be relative", + )); + } + // Check normalized path equals original (no .. or . components) + let parts: Vec<&str> = s.split('/').collect(); + for part in &parts { + if *part == "" || *part == "." || *part == ".." { + return Err(ControlError::invalid_request("invalid workspace path")); + } + } + Ok(parts.iter().map(|s| s.to_string()).collect()) +} + +/// Like validate_relative_path but "." is allowed (means root). +pub fn validate_workspace_directory(value: &Value) -> Result, ControlError> { + if value.as_str() == Some(".") { + return Ok(vec![]); + } + validate_relative_path(value) +} + +pub fn validate_integer( + value: &Value, + min: i64, + max: i64, + label: &str, +) -> Result { + let n = value + .as_i64() + .ok_or_else(|| ControlError::invalid_request(format!("{label} must be an integer")))?; + // Reject booleans (JSON true/false become null via as_i64, so this is implicitly handled) + if n < min || n > max { + return Err(ControlError::invalid_request(format!( + "{label} is outside the allowed range" + ))); + } + Ok(n) +} + +pub fn validate_job_id(value: &Value) -> Result { + let s = value + .as_str() + .ok_or_else(|| ControlError::invalid_request("job id must be a UUID"))?; + // Parse as UUID and check canonical form + let parsed = + parse_uuid(s).ok_or_else(|| ControlError::invalid_request("job id must be a UUID"))?; + if parsed != s { + return Err(ControlError::invalid_request( + "job id must be a canonical lowercase UUID", + )); + } + Ok(s.to_string()) +} + +fn parse_uuid(s: &str) -> Option { + let bytes = s.as_bytes(); + if bytes.len() != 36 { + return None; + } + if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' { + return None; + } + for i in 0..36 { + if i == 8 || i == 13 || i == 18 || i == 23 { + continue; + } + if !bytes[i].is_ascii_hexdigit() { + return None; + } + } + // Check version nibble (position 14) is '4' + if bytes[14] != b'4' { + return None; + } + // Check variant nibble (position 19) is 8/9/a/b + match bytes[19] { + b'8' | b'9' | b'a' | b'b' => {} + _ => return None, + } + Some(s.to_lowercase()) +} + +pub fn validate_environment( + value: &Value, +) -> Result, ControlError> { + let obj = value + .as_object() + .ok_or_else(|| ControlError::invalid_request("job environment must be an object"))?; + if obj.len() > 64 { + return Err(ControlError::invalid_request( + "job environment must contain at most 64 variables", + )); + } + let mut result = std::collections::BTreeMap::new(); + let mut total = 0usize; + for (name, item) in obj { + if !ENV_NAME.is_match(name) { + return Err(ControlError::invalid_request( + "job environment contains an invalid name", + )); + } + let allowed = USER_ENV_EXACT.contains(&name.as_str()) + || USER_ENV_PREFIXES.iter().any(|p| name.starts_with(p)); + if !allowed { + return Err(ControlError::permission_denied(format!( + "job environment variable is not allowlisted: {name}" + ))); + } + let val = item.as_str().ok_or_else(|| { + ControlError::invalid_request(format!("job environment value is invalid: {name}")) + })?; + if val.contains('\0') { + return Err(ControlError::invalid_request(format!( + "job environment value is invalid: {name}" + ))); + } + let encoded_len = val.len(); + if encoded_len > 8192 { + return Err(ControlError::limit_exceeded(format!( + "job environment value is too large: {name}" + ))); + } + total += name.len() + encoded_len; + if total > 32 * 1024 { + return Err(ControlError::limit_exceeded( + "job environment exceeds the aggregate size limit", + )); + } + result.insert(name.clone(), val.to_string()); + } + Ok(result) +} diff --git a/ops/images/build-demo-image.sh b/ops/images/build-demo-image.sh index 4bcbac7..f743d1b 100755 --- a/ops/images/build-demo-image.sh +++ b/ops/images/build-demo-image.sh @@ -53,8 +53,14 @@ mapfile -t packages < <(sed -E '/^[[:space:]]*(#|$)/d' "$repo_root/ops/images/pa install -D -m 0755 "$repo_root/ops/images/guest/bloom-init" "$scratch/rootfs/usr/local/sbin/bloom-init" install -D -m 0755 "$repo_root/ops/images/guest/bloom-workspace-device" "$scratch/rootfs/usr/local/sbin/bloom-workspace-device" install -D -m 0755 "$repo_root/ops/images/guest/bloom-workspace-identity" "$scratch/rootfs/usr/local/sbin/bloom-workspace-identity" -install -D -m 0755 "$repo_root/ops/guest-control/bloom-guest-control.py" "$scratch/rootfs/usr/local/libexec/bloom-guest-control" -install -D -m 0755 "$repo_root/ops/guest-control/bloom-workspace" "$scratch/rootfs/usr/local/bin/bloom-workspace" +# Build the Rust guest-control binary (static musl for Alpine) +guest_control_bin="$repo_root/ops/guest-control/target/x86_64-unknown-linux-musl/release/bloom-guest-control" +if [[ ! -x "$guest_control_bin" ]]; then + printf 'Building Rust guest-control binary (musl)...\n' >&2 + (cd "$repo_root/ops/guest-control" && cargo build --release --target x86_64-unknown-linux-musl) +fi +install -D -m 0755 "$guest_control_bin" "$scratch/rootfs/usr/local/libexec/bloom-guest-control" +install -D -m 0755 "$guest_control_bin" "$scratch/rootfs/usr/local/bin/bloom-workspace" install -D -m 0755 "$repo_root/ops/bloom/guest-bootstrap.sh" "$scratch/rootfs/usr/local/sbin/bloom-guest-bootstrap" install -D -m 0755 "$repo_root/ops/connections/workspace-ssh-session" "$scratch/rootfs/usr/local/libexec/bloom-workspace-shell" install -D -m 0755 "$bloom_artifact" "$scratch/rootfs/usr/local/bin/bloom" @@ -102,7 +108,7 @@ provenance="$artifact_dir/bloom-alpine.provenance.txt" printf 'bloom_cli=installed-static-musl\n' printf 'bloom_cli_release=v0.1.3\n' printf 'bloom_cli_sha256=%s\n' "$(sha256sum "$bloom_artifact" | cut -d' ' -f1)" - printf 'guest_control_sha256=%s\n' "$(sha256sum "$repo_root/ops/guest-control/bloom-guest-control.py" | cut -d' ' -f1)" + printf 'guest_control_sha256=%s\n' "$(sha256sum "$guest_control_bin" | cut -d' ' -f1)" } >"$provenance" truncate -s 4G "$artifact_dir/bloom-alpine.ext4" diff --git a/ops/images/packages.lock b/ops/images/packages.lock index 901a7ff..113656a 100644 --- a/ops/images/packages.lock +++ b/ops/images/packages.lock @@ -20,8 +20,6 @@ npm=11.12.1-r0 openssh-client-default=10.3_p1-r0 openssh-keygen=10.3_p1-r0 openssh-server=10.3_p1-r0 -py3-pip=26.1.2-r0 -python3=3.14.5-r0 ripgrep=15.1.0-r0 socat=1.8.1.3-r0 tmux=3.6b-r0 diff --git a/src/agent/server.ts b/src/agent/server.ts index c52bf54..a75fc9d 100644 --- a/src/agent/server.ts +++ b/src/agent/server.ts @@ -203,26 +203,10 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { }); } catch (error) { next(error); } }); - app.get("/v1/workspaces/:id/outbox/pending", async (request, response, next) => { + app.get("/v1/workspaces/:id/ceremony", async (request, response, next) => { try { const call = requireGuest(runtime, request.params.id ?? ""); - const result = await call({ version: 1, id: `outbox_pending_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.pending" }, 10_000); - response.json(result); - } catch (error) { next(error); } - }); - app.post("/v1/workspaces/:id/outbox/confirm", async (request, response, next) => { - try { - const call = requireGuest(runtime, request.params.id ?? ""); - const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), confirmText: z.string().min(1) }).strict().parse(request.body); - const result = await call({ version: 1, id: `outbox_confirm_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.confirm", txId: body.id, chain: body.chain, wallet: body.wallet, confirmText: body.confirmText }, 10_000); - response.json(result); - } catch (error) { next(error); } - }); - app.post("/v1/workspaces/:id/outbox/cancel", async (request, response, next) => { - try { - const call = requireGuest(runtime, request.params.id ?? ""); - const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/) }).strict().parse(request.body); - const result = await call({ version: 1, id: `outbox_cancel_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "outbox.cancel", txId: body.id, chain: body.chain, wallet: body.wallet }, 10_000); + const result = await call({ version: 1, id: `ceremony_pending_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "ceremony.pending" }, 10_000); response.json(result); } catch (error) { next(error); } }); diff --git a/src/control/agent-client.ts b/src/control/agent-client.ts index 56c489b..76e164b 100644 --- a/src/control/agent-client.ts +++ b/src/control/agent-client.ts @@ -30,9 +30,7 @@ export class AgentClient { } cancelJob(id: string, jobId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/jobs/${encodeURIComponent(jobId)}`); } bloomStatus(id: string) { return this.request("GET", `/v1/workspaces/${encodeURIComponent(id)}/bloom`); } - outboxPending(id: string) { return this.request<{ requests: { id: string; chain: string; wallet: string; planMd: string }[] }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/outbox/pending`); } - outboxConfirm(id: string, body: { id: string; chain: string; wallet: string; confirmText: string }) { return this.request>("POST", `/v1/workspaces/${encodeURIComponent(id)}/outbox/confirm`, body); } - outboxCancel(id: string, body: { id: string; chain: string; wallet: string }) { return this.request>("POST", `/v1/workspaces/${encodeURIComponent(id)}/outbox/cancel`, body); } + ceremonyPending(id: string) { return this.request<{ requests: { id: string; chain: string; wallet: string; planMd: string; ceremonyUrl: string | null }[] }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/ceremony`); } connections(id: string) { return this.request<{ connections: Record<"ssh" | "nfs", { status: "available" | "disabled" | "unsupported"; reason: string; instructions: string[] }> }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/connections`); } issueSsh(id: string, body: SshLeaseBody) { return this.request("POST", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh`, body); } revokeSsh(id: string, leaseId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh/${encodeURIComponent(leaseId)}`); } diff --git a/src/control/auth.ts b/src/control/auth.ts index c50da8a..ca6f63c 100644 --- a/src/control/auth.ts +++ b/src/control/auth.ts @@ -22,7 +22,7 @@ export function issueChallenge(db: BloomDatabase, config: Config, ipHash: string domain: url.host, uri: url.origin, chainId: config.authChainId, - statement: "Sign in to request a Bloom workspace. Workspace processes may request wallet signatures; you will approve each request in your wallet.", + statement: "Sign in to request a Bloom workspace. Transaction signing uses Bloom's Sealed Approval ceremony — you will approve each transaction through a secure local ceremony URL, never through this browser session.", issuedAt: issuedAt.toISOString(), expirationTime: expirationTime.toISOString(), }; diff --git a/src/control/http-security.test.ts b/src/control/http-security.test.ts index 4afed93..a323ae2 100644 --- a/src/control/http-security.test.ts +++ b/src/control/http-security.test.ts @@ -34,54 +34,12 @@ async function setup() { return { control, config, db, cookie, csrf, workspaceId }; } -describe("HTTP security: outbox path traversal rejection", () => { - it("rejects path traversal payloads in outbox confirm", async () => { - const { control, config, cookie, csrf, workspaceId } = await setup(); - - for (const payload of ["../../..", "..%2f..%2f", "foo/bar", "..", "a\\b"]) { - const response = await request(control.app) - .post(`/api/workspaces/${workspaceId}/outbox/confirm`) - .set(headers(config.origin, cookie, csrf)) - .send({ id: payload, chain: "8453", wallet: "default", confirmText: "ok" }); - expect(response.status, `confirm id="${payload}"`).toBeGreaterThanOrEqual(400); - } - }); - - it("rejects path traversal payloads in outbox cancel", async () => { - const { control, config, cookie, csrf, workspaceId } = await setup(); - - for (const payload of ["../../..", "..%2f..%2f", "foo/bar", "..", "a\\b"]) { - const response = await request(control.app) - .post(`/api/workspaces/${workspaceId}/outbox/cancel`) - .set(headers(config.origin, cookie, csrf)) - .send({ id: payload, chain: "8453", wallet: "default" }); - expect(response.status, `cancel id="${payload}"`).toBeGreaterThanOrEqual(400); - } - }); - - it("rejects path traversal in chain and wallet fields", async () => { - const { control, config, cookie, csrf, workspaceId } = await setup(); - - // chain traversal - const chainResponse = await request(control.app) - .post(`/api/workspaces/${workspaceId}/outbox/confirm`) - .set(headers(config.origin, cookie, csrf)) - .send({ id: "tx-1", chain: "../../etc", wallet: "default", confirmText: "ok" }); - expect(chainResponse.status).toBeGreaterThanOrEqual(400); - - // wallet traversal - const walletResponse = await request(control.app) - .post(`/api/workspaces/${workspaceId}/outbox/cancel`) - .set(headers(config.origin, cookie, csrf)) - .send({ id: "tx-1", chain: "8453", wallet: "../../secret" }); - expect(walletResponse.status).toBeGreaterThanOrEqual(400); - }); - +describe("HTTP security: ceremony endpoint", () => { it("rejects requests without authentication", async () => { const { control, workspaceId } = await setup(); const response = await request(control.app) - .get(`/api/workspaces/${workspaceId}/outbox/pending`); + .get(`/api/workspaces/${workspaceId}/ceremony`); expect(response.status).toBe(401); }); @@ -90,10 +48,20 @@ describe("HTTP security: outbox path traversal rejection", () => { const outsider = createTestSession(db, config, "wallet-b"); const response = await request(control.app) - .get(`/api/workspaces/${workspaceId}/outbox/pending`) + .get(`/api/workspaces/${workspaceId}/ceremony`) .set("cookie", outsider.cookie); expect(response.status).toBe(404); }); + + it("returns ceremony info for the workspace owner", async () => { + const { control, config, cookie, csrf, workspaceId } = await setup(); + + const response = await request(control.app) + .get(`/api/workspaces/${workspaceId}/ceremony`) + .set(headers(config.origin, cookie, csrf)); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("ceremonyUrl"); + }); }); describe("HTTP: metrics endpoint", () => { diff --git a/src/control/jobs-guest.test.ts b/src/control/jobs-guest.test.ts index fc66363..f8e87b3 100644 --- a/src/control/jobs-guest.test.ts +++ b/src/control/jobs-guest.test.ts @@ -9,8 +9,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { GuestResponse } from "../guest-protocol.js"; const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); -const servicePath = join(repoRoot, "ops/guest-control/bloom-guest-control.py"); -const helperPath = join(repoRoot, "ops/guest-control/bloom-workspace"); +const servicePath = join(repoRoot, "ops/guest-control/target/debug/bloom-guest-control"); +const helperPath = servicePath; const cleanups: Array<() => Promise> = []; afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); }); @@ -56,7 +56,7 @@ describe("guest job, file, and Bloom control service", () => { result: { identity: { kind: "watch", address: "0x1111111111111111111111111111111111111111" }, mount: { path: "/bloom" }, - capabilities: { files: true, jobs: true, walletSigning: true, transactions: true }, + capabilities: { files: true, jobs: true, walletSigning: false, transactions: false }, }, }); @@ -70,23 +70,33 @@ describe("guest job, file, and Bloom control service", () => { it("runs literal structured argv with an allowlisted environment as uid 1000 and no-new-privileges", async () => { const harness = await GuestHarness.create(); const script = [ - "import json, os, pathlib, sys", - "status = pathlib.Path('/proc/self/status').read_text()", - "nnp = next(line.split()[1] for line in status.splitlines() if line.startswith('NoNewPrivs:'))", - "print(json.dumps({'uid': os.getuid(), 'gid': os.getgid(), 'nnp': nnp, 'value': os.environ['APP_VALUE'], 'arg': sys.argv[1], 'cwd': os.getcwd(), 'has_loader': 'LD_PRELOAD' in os.environ}), flush=True)", - ].join("\n"); + "const fs=require('fs');", + "const s=fs.readFileSync('/proc/self/status','utf8');", + "const nnp=s.split('\n').find(l=>l.startsWith('NoNewPrivs:')).split(/\s+/)[1];", + "console.log(JSON.stringify({uid:process.getuid(),gid:process.getgid(),nnp,value:process.env.APP_VALUE,arg:process.argv[1],cwd:process.cwd(),has_loader:'LD_PRELOAD' in process.env}));", + ].join(""); const jobId = randomUUID(); const started = await harness.request({ operation: "job.start", jobId, - argv: ["python3", "-c", script, "; echo this-would-be-shell-injection"], + argv: ["node", "-e", script, "; echo this-would-be-shell-injection"], cwd: ".", environment: { APP_VALUE: "hello" }, timeoutMs: 10_000, }); + // Debug: print the script that was sent + const idx = script.indexOf("split("); + console.error("SCRIPT_HEX_AROUND_SPLIT:", Buffer.from(script.substring(idx, idx + 20)).toString("hex")); + console.error("SCRIPT_HAS_NEWLINE:", script.includes("\n"), "SCRIPT_LEN:", script.length); expect(started).toMatchObject({ ok: true, result: { jobId, state: "running" } }); const completed = await harness.waitForTerminal(jobId); + if (completed.state !== "succeeded") { + console.error("JOB FAILED:", JSON.stringify(completed, null, 2)); + if (completed.logs?.data) { + console.error("LOGS:", Buffer.from(completed.logs.data, "base64").toString("utf8")); + } + } expect(completed).toMatchObject({ state: "succeeded", exitCode: 0 }); const output = JSON.parse(Buffer.from(completed.logs.data, "base64").toString("utf8")); expect(output).toMatchObject({ uid: 1000, gid: 1000, nnp: "1", value: "hello", arg: "; echo this-would-be-shell-injection", has_loader: false }); @@ -106,8 +116,8 @@ describe("guest job, file, and Bloom control service", () => { const harness = await GuestHarness.create(); const cancellableId = randomUUID(); const timeoutId = randomUUID(); - const childScript = "import signal,subprocess,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); p=subprocess.Popen(['sleep','30']); print(p.pid,flush=True); time.sleep(30)"; - expect(await harness.request({ operation: "job.start", jobId: cancellableId, argv: ["python3", "-c", childScript], cwd: "src", environment: {}, timeoutMs: 30_000 })) + const childScript = "process.on('SIGTERM',()=>{});const cp=require('child_process');const p=cp.spawn('sleep',['30'],{stdio:'ignore'});console.log(p.pid);setInterval(()=>{},60000);"; + expect(await harness.request({ operation: "job.start", jobId: cancellableId, argv: ["node", "-e", childScript], cwd: "src", environment: {}, timeoutMs: 30_000 })) .toMatchObject({ ok: true, result: { state: "running" } }); expect(await harness.request({ operation: "job.start", jobId: timeoutId, argv: ["sleep", "30"], cwd: "src", environment: {}, timeoutMs: 1000 })) .toMatchObject({ ok: true, result: { state: "running" } }); @@ -128,7 +138,7 @@ describe("guest job, file, and Bloom control service", () => { expect(await harness.request({ operation: "job.start", jobId: largeId, - argv: ["python3", "-c", "import sys; sys.stdout.write('x' * (1200 * 1024)); sys.stdout.flush()"], + argv: ["node", "-e", "process.stdout.write('x'.repeat(1200*1024))"], cwd: "src", environment: {}, timeoutMs: 10_000, @@ -146,8 +156,7 @@ describe("guest job, file, and Bloom control service", () => { const root = await mkdtemp(join(tmpdir(), "bloom-guest-helper-")); const socketPath = join(root, "run/guest-control.sock"); await mkdir(join(root, "src")); - const child = spawn("python3", [ - servicePath, + const child = spawn(servicePath, [ "--workspace", root, "--workspace-quota-bytes", String(1024 * 1024), "--job-uid", String(process.getuid?.() ?? 1000), @@ -207,7 +216,6 @@ class GuestHarness { await mkdir(join(root, "src"), { recursive: true }); const socketPath = withUnixSocket ? join(root, "guest-control.sock") : undefined; const argumentsList = [ - servicePath, "--stdio", "--workspace", root, "--workspace-quota-bytes", String(16 * 1024 * 1024), @@ -215,7 +223,7 @@ class GuestHarness { "--job-gid", String(process.getgid?.() ?? 1000), ]; if (socketPath) argumentsList.push("--unix-socket", socketPath); - const child = spawn("python3", argumentsList, { stdio: "pipe" }); + const child = spawn(servicePath, argumentsList, { stdio: "pipe" }); const harness = new GuestHarness(root, child, socketPath); cleanups.push(async () => harness.close()); if (socketPath) { diff --git a/src/control/server.ts b/src/control/server.ts index 6f0ec78..3a82e4a 100644 --- a/src/control/server.ts +++ b/src/control/server.ts @@ -262,26 +262,10 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { response.json(await workspaces.bloomStatus(session.wallet, String(request.params.id ?? ""))); } catch (error) { next(error); } }); - app.get("/api/workspaces/:id/outbox/pending", requireSession, async (request, response, next) => { + app.get("/api/workspaces/:id/ceremony", requireSession, async (request, response, next) => { try { const { session } = response.locals.context as { session: Session }; - response.json(await workspaces.outboxPending(session.wallet, String(request.params.id ?? ""))); - } catch (error) { next(error); } - }); - app.post("/api/workspaces/:id/outbox/confirm", requireSession, async (request, response, next) => { - try { - const { session } = response.locals.context as { session: Session }; - const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), confirmText: z.string().min(1) }).strict().parse(request.body); - await workspaces.outboxConfirm(session.wallet, String(request.params.id ?? ""), body); - response.status(204).end(); - } catch (error) { next(error); } - }); - app.post("/api/workspaces/:id/outbox/cancel", requireSession, async (request, response, next) => { - try { - const { session } = response.locals.context as { session: Session }; - const body = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), chain: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), wallet: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/) }).strict().parse(request.body); - await workspaces.outboxCancel(session.wallet, String(request.params.id ?? ""), body); - response.status(204).end(); + response.json(await workspaces.ceremonyPending(session.wallet, String(request.params.id ?? ""))); } catch (error) { next(error); } }); app.get("/api/workspaces/:id/connections", requireSession, async (request, response, next) => { @@ -314,7 +298,7 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { const clientPlan = !clientInput ? undefined : grant.mode === "shell" - ? { sshArgv: createSshClientArgv(clientInput) } + ? { sshArgv: createSshClientArgv(clientInput), ceremonyArgv: [...createSshClientArgv(clientInput), "-L", "18734:localhost:18734"] } : createNfsClientPlan({ ...clientInput, platform: body.client!.platform, diff --git a/src/control/workspaces.ts b/src/control/workspaces.ts index 92063c7..4f01066 100644 --- a/src/control/workspaces.ts +++ b/src/control/workspaces.ts @@ -150,26 +150,11 @@ export class WorkspaceService { catch (error) { throw this.agentWorkspaceError(error); } } - async outboxPending(wallet: string, id: string) { - const row = this.ownedRunning(wallet, id, "outbox_pending"); - try { return await this.agent.outboxPending(row.id); } - catch (error) { throw this.agentWorkspaceError(error); } - } - - async outboxConfirm(wallet: string, id: string, body: { id: string; chain: string; wallet: string; confirmText: string }) { - const row = this.ownedRunning(wallet, id, "outbox_confirm"); - try { - const result = await this.agent.outboxConfirm(row.id, body); - audit(this.db, "workspace.outbox_confirmed", row.wallet, row.id, { txId: body.id, chain: body.chain, wallet: body.wallet }); - return result; - } catch (error) { throw this.agentWorkspaceError(error); } - } - - async outboxCancel(wallet: string, id: string, body: { id: string; chain: string; wallet: string }) { - const row = this.ownedRunning(wallet, id, "outbox_cancel"); + async ceremonyPending(wallet: string, id: string) { + const row = this.ownedRunning(wallet, id, "ceremony_pending"); try { - const result = await this.agent.outboxCancel(row.id, body); - audit(this.db, "workspace.outbox_cancelled", row.wallet, row.id, { txId: body.id, chain: body.chain, wallet: body.wallet }); + const result = await this.agent.ceremonyPending(row.id); + audit(this.db, "workspace.ceremony_polled", row.wallet, row.id, { count: result.requests.length }); return result; } catch (error) { throw this.agentWorkspaceError(error); } } diff --git a/src/guest-protocol.test.ts b/src/guest-protocol.test.ts index c2ce3ec..9f414ab 100644 --- a/src/guest-protocol.test.ts +++ b/src/guest-protocol.test.ts @@ -51,23 +51,17 @@ describe("guest control protocol", () => { expect(GuestRequest.safeParse({ ...request, caPublicKey: "-----BEGIN OPENSSH PRIVATE KEY-----" }).success).toBe(false); }); - it("rejects path traversal in outbox confirm and cancel identifiers", () => { - const validConfirm = { ...envelope, operation: "outbox.confirm", txId: "tx_abc123", chain: "8453", wallet: "my-wallet", confirmText: "confirmed" }; - const validCancel = { ...envelope, operation: "outbox.cancel", txId: "tx_abc123", chain: "8453", wallet: "my-wallet" }; - expect(GuestRequest.safeParse(validConfirm).success).toBe(true); - expect(GuestRequest.safeParse(validCancel).success).toBe(true); + it("accepts ceremony.pending and rejects unknown operations", () => { + const validCeremony = { ...envelope, operation: "ceremony.pending" }; + expect(GuestRequest.safeParse(validCeremony).success).toBe(true); - // Path traversal payloads must be rejected at the protocol layer - for (const traversal of ["../../..", "foo/bar", "..", ".", "a\\b", "x\0y"]) { - const confirmPayload = { ...envelope, operation: "outbox.confirm", txId: traversal, chain: "8453", wallet: "ok", confirmText: "ok" }; - const cancelPayload = { ...envelope, operation: "outbox.cancel", txId: "ok", chain: traversal, wallet: "ok" }; - expect(GuestRequest.safeParse(confirmPayload).success, `outbox.confirm txId="${traversal}"`).toBe(false); - expect(GuestRequest.safeParse(cancelPayload).success, `outbox.cancel chain="${traversal}"`).toBe(false); - } + // Old outbox operations are no longer in the protocol — must be rejected + expect(GuestRequest.safeParse({ ...envelope, operation: "outbox.confirm", txId: "tx_abc123", chain: "8453", wallet: "my-wallet", confirmText: "confirmed" }).success).toBe(false); + expect(GuestRequest.safeParse({ ...envelope, operation: "outbox.cancel", txId: "tx_abc123", chain: "8453", wallet: "my-wallet" }).success).toBe(false); - // Empty strings must be rejected - expect(GuestRequest.safeParse({ ...validConfirm, txId: "" }).success).toBe(false); - expect(GuestRequest.safeParse({ ...validCancel, wallet: "" }).success).toBe(false); + // Completely unknown operations must be rejected + expect(GuestRequest.safeParse({ ...envelope, operation: "admin.shutdown" }).success).toBe(false); + expect(GuestRequest.safeParse({ ...envelope, operation: "shell.exec" }).success).toBe(false); }); it("frames partial and multiple responses without unbounded buffering", () => { diff --git a/src/guest-protocol.ts b/src/guest-protocol.ts index 3b9bbf4..997bcb1 100644 --- a/src/guest-protocol.ts +++ b/src/guest-protocol.ts @@ -7,10 +7,7 @@ export const MAX_FILE_CHUNK_BYTES = 256 * 1024; export const MAX_JOB_LOG_CHUNK_BYTES = 256 * 1024; const RequestId = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); -/** Safe identifier for Bloom outbox VFS paths — blocks path traversal characters. */ -const OutboxId = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); -const OutboxChain = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/); -const OutboxWallet = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); +/** Safe identifier for Bloom VFS paths (ceremony pending scan). */ const RelativePath = z.string().min(1).max(1024).refine(isSafeWorkspacePath, "unsafe workspace path"); const WorkspaceDirectory = z.union([z.literal("."), RelativePath]); const Base64Chunk = z.string().max(Math.ceil(MAX_FILE_CHUNK_BYTES / 3) * 4 + 4) @@ -63,20 +60,7 @@ export const GuestRequest = z.discriminatedUnion("operation", [ caPublicKey: z.string().min(32).max(1024).regex(/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/), nfs: z.boolean(), }), - Envelope.extend({ operation: z.literal("outbox.pending") }), - Envelope.extend({ - operation: z.literal("outbox.confirm"), - txId: OutboxId, - chain: OutboxChain, - wallet: OutboxWallet, - confirmText: z.string().min(1), - }), - Envelope.extend({ - operation: z.literal("outbox.cancel"), - txId: OutboxId, - chain: OutboxChain, - wallet: OutboxWallet, - }), + Envelope.extend({ operation: z.literal("ceremony.pending") }), ]); export type GuestRequest = z.infer; diff --git a/src/guest/results.ts b/src/guest/results.ts index 04b8eda..7afab7e 100644 --- a/src/guest/results.ts +++ b/src/guest/results.ts @@ -10,8 +10,8 @@ export const BloomGuestStatus = z.object({ files: z.boolean(), jobs: z.boolean(), bloomRead: z.boolean(), - walletSigning: z.boolean(), - transactions: z.boolean(), + walletSigning: z.literal(false), + transactions: z.literal(false), }), helper: z.object({ name: z.literal("bloom-workspace"), protocolVersion: z.literal(1) }), }).superRefine((status, context) => { diff --git a/web/index.html b/web/index.html index f8a32d7..1923db1 100644 --- a/web/index.html +++ b/web/index.html @@ -139,10 +139,10 @@

Workspace console