From 133b1ebfafc3ae040b9b23ff3fcad49e273c9cf9 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 20 Oct 2026 13:36:20 +0000 Subject: [PATCH 01/98] sync release versions for v2.1.1 [skip ci] --- bun.lock | 64 +++++++++++++-------------- package.json | 2 +- packages/ai/package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/client/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/function/package.json | 2 +- packages/gui-extensions/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/httpapi-codegen/package.json | 2 +- packages/latex/package.json | 2 +- packages/merman/package.json | 2 +- packages/plugin-browser/package.json | 2 +- packages/plugin/package.json | 2 +- packages/plugins/figma/package.json | 2 +- packages/protocol/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/simulation/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/theme/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- services/update/package.json | 2 +- 35 files changed, 66 insertions(+), 66 deletions(-) diff --git a/bun.lock b/bun.lock index 7a26504b997..5a81fa236cb 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,7 @@ }, "packages/ai": { "name": "@opencode/ai", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "workspace:*", @@ -56,7 +56,7 @@ }, "packages/app": { "name": "@opencode/app", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -113,7 +113,7 @@ }, "packages/cli": { "name": "@opencode/cli", - "version": "2.1.0", + "version": "2.1.1", "bin": { "opencode": "./bin/opencode.cjs", "opencode2": "./bin/opencode2.cjs", @@ -181,7 +181,7 @@ }, "packages/client": { "name": "@opencode/client", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/protocol": "workspace:*", "@opencode/schema": "workspace:*", @@ -206,7 +206,7 @@ }, "packages/codemode": { "name": "@opencode/codemode", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -219,7 +219,7 @@ }, "packages/console/app": { "name": "@opencode/console-app", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -259,7 +259,7 @@ }, "packages/core": { "name": "@opencode/core", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@ai-sdk/cohere": "3.0.27", "@ai-sdk/perplexity": "3.0.26", @@ -325,7 +325,7 @@ }, "packages/desktop": { "name": "@opencode/desktop", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@zip.js/zip.js": "2.7.62", "electron-context-menu": "5.0.0", @@ -371,7 +371,7 @@ }, "packages/function": { "name": "@opencode/function", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -387,7 +387,7 @@ }, "packages/gui-extensions": { "name": "@opencode/gui-extensions", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@betteroffice/docx": "0.4.3", "@betteroffice/fonts": "0.4.0", @@ -436,7 +436,7 @@ }, "packages/http-recorder": { "name": "@opencode/http-recorder", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@effect/platform-node-shared": "catalog:", }, @@ -455,7 +455,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode/httpapi-codegen", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -468,7 +468,7 @@ }, "packages/latex": { "name": "@opencode/latex", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -482,7 +482,7 @@ }, "packages/merman": { "name": "@opencode/merman", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -497,7 +497,7 @@ }, "packages/plugin": { "name": "@opencode/plugin", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "workspace:*", @@ -534,7 +534,7 @@ }, "packages/plugin-browser": { "name": "@opencode/plugin-browser", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/plugin": "workspace:*", "@opencode/schema": "workspace:*", @@ -549,7 +549,7 @@ }, "packages/plugins/figma": { "name": "@opencode/plugin-figma", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "gray-matter": "4.0.3", }, @@ -579,7 +579,7 @@ }, "packages/protocol": { "name": "@opencode/protocol", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/schema": "workspace:*", "effect": "catalog:", @@ -594,7 +594,7 @@ }, "packages/schema": { "name": "@opencode/schema", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@standard-schema/spec": "catalog:", "effect": "catalog:", @@ -618,7 +618,7 @@ }, "packages/sdk": { "name": "@opencode/sdk", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -639,7 +639,7 @@ }, "packages/server": { "name": "@opencode/server", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", @@ -662,7 +662,7 @@ }, "packages/session-ui": { "name": "@opencode/session-ui", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@kobalte/core": "catalog:", "@opencode/client": "workspace:*", @@ -697,7 +697,7 @@ }, "packages/simulation": { "name": "@opencode/simulation", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/ai": "workspace:*", "@opencode/core": "workspace:*", @@ -717,7 +717,7 @@ }, "packages/stats/app": { "name": "@opencode/stats-app", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -744,7 +744,7 @@ }, "packages/stats/core": { "name": "@opencode/stats-core", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@planetscale/database": "1.19.0", "drizzle-orm": "catalog:", @@ -762,7 +762,7 @@ }, "packages/stats/server": { "name": "@opencode/stats-server", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@effect/platform-node": "catalog:", "@opencode/stats-core": "workspace:*", @@ -808,7 +808,7 @@ }, "packages/theme": { "name": "@opencode/theme", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opentui/core": "catalog:", "effect": "catalog:", @@ -822,7 +822,7 @@ }, "packages/tui": { "name": "@opencode/tui", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -855,7 +855,7 @@ }, "packages/ui": { "name": "@opencode/ui", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -891,7 +891,7 @@ }, "packages/util": { "name": "@opencode/util", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -929,7 +929,7 @@ }, "packages/web": { "name": "@opencode/web", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -983,7 +983,7 @@ }, "services/update": { "name": "@opencode/update", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "jose": "6.0.11", "semver": "catalog:", diff --git a/package.json b/package.json index 8fc9526cf95..0b85bc72259 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "opencode", "description": "AI-powered development tool", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "packageManager": "bun@1.4.2", diff --git a/packages/ai/package.json b/packages/ai/package.json index 69cff6648b4..514ff6b1c63 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.1.0", + "version": "2.1.1", "name": "@opencode/ai", "type": "module", "license": "MIT", diff --git a/packages/app/package.json b/packages/app/package.json index 0b0bb9c87aa..b265f3ce450 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/app", - "version": "2.1.0", + "version": "2.1.1", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 4b4563798fd..26b41fe1df2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/cli", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "bin": { diff --git a/packages/client/package.json b/packages/client/package.json index adcafbee643..421af64101e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/client", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 7d6629de2c1..87304ebc614 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/codemode", - "version": "2.1.0", + "version": "2.1.1", "description": "Effect-native confined code execution over schema-described tools", "type": "module", "license": "MIT", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 762e8b6f35f..42b01cf1288 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/console-app", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 40b920c1eaa..553daa74cde 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.1.0", + "version": "2.1.1", "name": "@opencode/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ea76d565627..989e2dbd533 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode/desktop", "private": true, - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/function/package.json b/packages/function/package.json index 8ee0bdbdd14..85a81cbae14 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/function", - "version": "2.1.0", + "version": "2.1.1", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/gui-extensions/package.json b/packages/gui-extensions/package.json index 8a1c75247fb..9e869499897 100644 --- a/packages/gui-extensions/package.json +++ b/packages/gui-extensions/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/gui-extensions", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 8c62779aa5c..c99b04913e1 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.1.0", + "version": "2.1.1", "name": "@opencode/http-recorder", "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index 820c78d0cf6..8057174b671 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/httpapi-codegen", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "exports": { diff --git a/packages/latex/package.json b/packages/latex/package.json index c1d7de21add..d64583c8e8a 100644 --- a/packages/latex/package.json +++ b/packages/latex/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/latex", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "exports": { diff --git a/packages/merman/package.json b/packages/merman/package.json index 4419a24362d..1d1fd06965f 100644 --- a/packages/merman/package.json +++ b/packages/merman/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/merman", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "exports": { diff --git a/packages/plugin-browser/package.json b/packages/plugin-browser/package.json index 6d3302eda71..83c3a798775 100644 --- a/packages/plugin-browser/package.json +++ b/packages/plugin-browser/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin-browser", - "version": "2.1.0", + "version": "2.1.1", "description": "OpenCode's desktop browser plugin", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1723f248b63..536061b6211 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugins/figma/package.json b/packages/plugins/figma/package.json index b0fa0e94398..f23c0535fac 100644 --- a/packages/plugins/figma/package.json +++ b/packages/plugins/figma/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin-figma", - "version": "2.1.0", + "version": "2.1.1", "description": "Figma MCP integration and official Figma skills for OpenCode", "type": "module", "license": "MIT", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 46e10ffa450..fc29bd4fbb3 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/protocol", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/schema/package.json b/packages/schema/package.json index c492c9540a6..44c91de86b1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/schema", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7f58856913b..789aa067375 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.1.0", + "version": "2.1.1", "name": "@opencode/sdk", "type": "module", "license": "MIT", diff --git a/packages/server/package.json b/packages/server/package.json index 3bba41a6226..bf1c1fdea44 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/server", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 7898e375ab4..3b29a783a8d 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/session-ui", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 60642510028..ef03b0ae517 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/simulation", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 133677d85fa..e87a9e4d604 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/stats-app", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index e54789702e2..fbfdd749a02 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/stats-core", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 8b5968c64aa..3f5dbd28317 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/stats-server", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/theme/package.json b/packages/theme/package.json index 0970dbc6b4e..9a1ae1173a3 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/theme", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/tui/package.json b/packages/tui/package.json index d5c29f76106..ccbe9c92e35 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/tui", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index b110129324e..6a5f7a6d55f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/ui", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/util/package.json b/packages/util/package.json index 67f9234f7b8..7ca74027f94 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/util", - "version": "2.1.0", + "version": "2.1.1", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 33fa99b76fd..58dfcca308e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode/web", "type": "module", "license": "MIT", - "version": "2.1.0", + "version": "2.1.1", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index d538c7d41cc..4fd58a94e82 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "2.1.0", + "version": "2.1.1", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/services/update/package.json b/services/update/package.json index 39137bf16a0..59c5fe3b1ef 100644 --- a/services/update/package.json +++ b/services/update/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/update", - "version": "2.1.0", + "version": "2.1.1", "private": true, "type": "module", "scripts": { From 50c5fceea10546aabfbae872c66347af5b688c1c Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 20 Oct 2026 11:08:20 -0400 Subject: [PATCH 02/98] chore(function): remove remaining share endpoints (#57328) --- packages/function/src/api.ts | 148 +---------------------------------- 1 file changed, 4 insertions(+), 144 deletions(-) diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index e5bbfd05313..60e2fd72cdc 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -6,122 +6,11 @@ import { Octokit } from "@octokit/rest" import { Resource } from "sst" import { parseRepositoryClaim } from "./github" -type Env = { - SYNC_SERVER: DurableObjectNamespace - Bucket: R2Bucket -} +// Inert: the SYNC_SERVER binding and existing Durable Objects still reference this class, so it must stay exported. +export class SyncServer extends DurableObject {} -export class SyncServer extends DurableObject { - // oxlint-disable-next-line no-useless-constructor - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env) - } - async fetch() { - console.log("SyncServer subscribe") - - const webSocketPair = new WebSocketPair() - const [client, server] = Object.values(webSocketPair) - - this.ctx.acceptWebSocket(server) - - const data = await this.ctx.storage.list() - Array.from(data.entries()) - .filter(([key, _]) => key.startsWith("session/")) - .map(([key, content]) => server.send(JSON.stringify({ key, content }))) - - return new Response(null, { - status: 101, - webSocket: client, - }) - } - - async webSocketMessage(_ws, _message) {} - - async webSocketClose(ws, code, _reason, _wasClean) { - ws.close(code, "Durable Object is closing WebSocket") - } - - public async getData() { - const data = (await this.ctx.storage.list()) as Map - return Array.from(data.entries()) - .filter(([key, _]) => key.startsWith("session/")) - .map(([key, content]) => ({ key, content })) - } - - private async getSessionID() { - return this.ctx.storage.get("sessionID") - } - - async clear() { - const sessionID = await this.getSessionID() - const list = await this.env.Bucket.list({ - prefix: `session/message/${sessionID}/`, - limit: 1000, - }) - for (const item of list.objects) { - await this.env.Bucket.delete(item.key) - } - await this.env.Bucket.delete(`session/info/${sessionID}`) - await this.ctx.storage.deleteAll() - } - - static shortName(id: string) { - return id.substring(id.length - 8) - } -} - -export default new Hono<{ Bindings: Env }>() +export default new Hono() .get("/", (c) => c.text("Hello, world!")) - .post("/share_delete_admin", async (c) => { - const body = await c.req.json<{ sessionShortName: string; adminSecret: string }>() - const sessionShortName = body.sessionShortName - const adminSecret = body.adminSecret - if (adminSecret !== Resource.ADMIN_SECRET.value) throw new Error("Invalid admin secret") - const id = c.env.SYNC_SERVER.idFromName(sessionShortName) - const stub = c.env.SYNC_SERVER.get(id) - await stub.clear() - return c.json({}) - }) - .get("/share_poll", async (c) => { - const upgradeHeader = c.req.header("Upgrade") - if (!upgradeHeader || upgradeHeader !== "websocket") { - return c.text("Error: Upgrade header is required", { status: 426 }) - } - const id = c.req.query("id") - console.log("share_poll", id) - if (!id) return c.text("Error: Share ID is required", { status: 400 }) - const stub = c.env.SYNC_SERVER.get(c.env.SYNC_SERVER.idFromName(id)) - return stub.fetch(c.req.raw) - }) - .get("/share_data", async (c) => { - const id = c.req.query("id") - console.log("share_data", id) - if (!id) return c.text("Error: Share ID is required", { status: 400 }) - const stub = c.env.SYNC_SERVER.get(c.env.SYNC_SERVER.idFromName(id)) - const data = await stub.getData() - - let info - const messages: Record = {} - data.forEach((d) => { - const [root, type] = d.key.split("/") - if (root !== "session") return - if (type === "info") { - info = d.content - return - } - if (type === "message") { - messages[d.content.id] = { - parts: [], - ...d.content, - } - } - if (type === "part") { - messages[d.content.messageID].parts.push(d.content) - } - }) - - return c.json({ info, messages }) - }) .post("/feishu", async (c) => { const body = (await c.req.json()) as { challenge?: string @@ -276,33 +165,4 @@ export default new Hono<{ Bindings: Env }>() return c.json({ error }, { status: 401 }) } }) - /** - * Used by the opencode CLI to check if the GitHub app is installed - */ - .get("/get_github_app_installation", async (c) => { - const owner = c.req.query("owner") - const repo = c.req.query("repo") - - const auth = createAppAuth({ - appId: Resource.GITHUB_APP_ID.value, - privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, - }) - const appAuth = await auth({ type: "app" }) - - // Lookup installation - const octokit = new Octokit({ auth: appAuth.token }) - let installation - try { - const ret = await octokit.apps.getRepoInstallation({ owner, repo }) - installation = ret.data - } catch (err) { - if (err instanceof Error && err.message.includes("Not Found")) { - // not installed - } else { - throw err - } - } - - return c.json({ installation }) - }) - .all("*", (c) => c.text("Not Found")) + .all("*", (c) => c.text("Not Found", 404)) From 89d996c2c5b902c9467494d8abe01205830e1d4a Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 20 Oct 2026 11:36:08 -0400 Subject: [PATCH 03/98] fix(core): keep locations with running sessions or background jobs (#57416) --- packages/core/src/job.ts | 11 + packages/core/src/location-activity.ts | 54 ++--- packages/core/src/session/execution.ts | 9 +- packages/core/src/session/run-coordinator.ts | 28 +-- packages/core/test/browser-idle.test.ts | 3 +- packages/core/test/location-activity.test.ts | 243 ++++++++++--------- 6 files changed, 175 insertions(+), 173 deletions(-) diff --git a/packages/core/src/job.ts b/packages/core/src/job.ts index 771c5a79722..7afc0f17f22 100644 --- a/packages/core/src/job.ts +++ b/packages/core/src/job.ts @@ -133,6 +133,7 @@ export interface Interface { readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect readonly cancel: (id: string) => Effect.Effect readonly pendingBackground: Effect.Effect + readonly runningBackground: Effect.Effect readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect } @@ -449,6 +450,15 @@ export const make = Effect.gen(function* () { return recovered }).pipe(Effect.withSpan("Job.pendingBackground")) + const runningBackground: Interface["runningBackground"] = SynchronizedRef.get(state.jobs).pipe( + Effect.map((jobs) => + [...jobs.values()].flatMap((job) => + job.isBackgrounded && job.info.status === "running" && job.recovery ? [job.recovery] : [], + ), + ), + Effect.withSpan("Job.runningBackground"), + ) + const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) => SynchronizedRef.updateEffect(state.jobs, (jobs) => Effect.gen(function* () { @@ -471,6 +481,7 @@ export const make = Effect.gen(function* () { backgroundAll, cancel, pendingBackground, + runningBackground, completeBackground, }) }) diff --git a/packages/core/src/location-activity.ts b/packages/core/src/location-activity.ts index 0425e9acab0..9745f82a60d 100644 --- a/packages/core/src/location-activity.ts +++ b/packages/core/src/location-activity.ts @@ -2,6 +2,7 @@ export * as LocationActivity from "./location-activity.js" import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect" import { Bus } from "./bus.js" +import { Job } from "./job.js" import { Location } from "./location.js" import { LocationServiceMap } from "./location-service-map.js" import { SessionEvent } from "./session/event.js" @@ -21,6 +22,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly const bus = yield* Bus.Service const locations = yield* LocationServiceMap.Service const execution = yield* SessionExecution.Service + const jobs = yield* Job.Service const sessions = yield* SessionStore.Service const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes") const entries = new Map() @@ -50,37 +52,29 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly const now = clock.currentTimeMillisUnsafe() const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now) if (expired.length === 0) return - const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID)) + // Waiting on a question, a long tool call, or a background job emits no durable activity. + const owners = [ + ...(yield* execution.active), + ...(yield* jobs.runningBackground).flatMap((recovery) => + recovery.kind === "shell" ? [recovery.sessionID] : [recovery.childSessionID, recovery.parentSessionID], + ), + ] + const occupied = new Set( + (yield* Effect.forEach(owners, (sessionID) => sessions.get(sessionID))).flatMap((session) => + session ? [key(session.location)] : [], + ), + ) yield* Effect.forEach( expired, - (entry) => - Effect.gen(function* () { - const owners = active.flatMap((session) => - session && key(session.location) === key(entry.ref) ? [session] : [], - ) - // Invalidation only detaches the cache entry; borrowers retain the old - // graph. Stop its executions and settle tool cleanup before detaching it. - yield* Effect.forEach( - owners, - (session) => execution.interrupt(session.id, { reason: "inactivity", awaitSettlement: true }), - { - discard: true, - concurrency: "unbounded", - }, - ) - const remaining = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID)) - // New work admitted during cleanup may now own the cached graph. - if (remaining.some((session) => session && key(session.location) === key(entry.ref))) { - yield* touch(entry.ref) - return - } - entries.delete(key(entry.ref)) - yield* Effect.logInfo("location services evicted", { - directory: entry.ref.directory, - workspaceID: entry.ref.workspaceID, - }).pipe(Effect.andThen(locations.invalidate(entry.ref))) - }), - { discard: true, concurrency: "unbounded" }, + (entry) => { + if (occupied.has(key(entry.ref))) return touch(entry.ref) + entries.delete(key(entry.ref)) + return Effect.logInfo("location services evicted", { + directory: entry.ref.directory, + workspaceID: entry.ref.workspaceID, + }).pipe(Effect.andThen(locations.invalidate(entry.ref))) + }, + { discard: true }, ) }).pipe(Effect.forever, Effect.forkScoped) @@ -92,5 +86,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly export const node = makeGlobalNode({ service: Service, layer: layer(), - deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node], + deps: [Bus.node, Job.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node], }) diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index eeed241ae14..fb7a9d3a644 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -27,15 +27,12 @@ export interface Interface { * Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once * the interruption is accepted; cleanup settles asynchronously in the execution fiber. * Returns whether an active execution was interrupted. Compose with `awaitIdle` when - * settlement matters. `awaitSettlement` waits only for the interrupted execution, - * rather than fresh work admitted during its cleanup. + * settlement matters. */ readonly interrupt: ( sessionID: SessionSchema.ID, options?: { readonly resume?: boolean - readonly reason?: "user" | "inactivity" - readonly awaitSettlement?: boolean }, ) => Effect.Effect /** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */ @@ -45,7 +42,7 @@ export interface Interface { /** Routes execution from a Session ID to its selected instance's runner. */ export class Service extends Context.Service()("@opencode/SessionExecution") {} -type InterruptReason = "user" | "shutdown" | "inactivity" +type InterruptReason = "user" | "shutdown" export function terminal(exit: Exit.Exit, reason?: InterruptReason) { if (Exit.isSuccess(exit)) return { type: "succeeded" as const } @@ -152,7 +149,7 @@ export const layer = Layer.effect( isActive: coordinator.isActive, interrupt: (sessionID, options) => Effect.gen(function* () { - const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options) + const interrupted = yield* coordinator.interrupt(sessionID, "user") if (!options?.resume) return interrupted // Resume steering input and between-turn control work from the interrupted // intent. Queued next-turn prompts stay parked: a steer-scoped drain never diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index 9c4ba4b09a9..a8e75319f1f 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -17,14 +17,9 @@ export interface Coordinator { * Stops the active execution and clears its doorbell. No-op when idle. Resolves once the * interruption is accepted, not when cleanup settles: the execution fiber finishes its * finalizers and settled hook on its own time. Returns whether an active execution was - * interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook, - * without following fresh work admitted during cleanup. `awaitIdle` follows successors too. + * interrupted. */ - readonly interrupt: ( - key: Key, - reason?: Reason, - options?: { readonly awaitSettlement?: boolean }, - ) => Effect.Effect + readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ readonly awaitIdle: (key: Key) => Effect.Effect } @@ -175,22 +170,5 @@ export const make = (options: { return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key))) }) - return { - active: Effect.sync(() => new Set(executions.keys())), - isActive, - run, - wake, - interrupt: (key, reason, options) => - Effect.suspend(() => { - const execution = executions.get(key) - return interrupt(key, reason).pipe( - Effect.tap(() => - options?.awaitSettlement && execution - ? Deferred.await(execution.done).pipe(Effect.ignoreCause) - : Effect.void, - ), - ) - }), - awaitIdle, - } + return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle } }) diff --git a/packages/core/test/browser-idle.test.ts b/packages/core/test/browser-idle.test.ts index c0f6fc5ef22..634b05f9721 100644 --- a/packages/core/test/browser-idle.test.ts +++ b/packages/core/test/browser-idle.test.ts @@ -8,6 +8,7 @@ import { Global } from "@opencode/util/global" import { Bus } from "@opencode/core/bus" import { Database } from "@opencode/core/database/database" import { Location } from "@opencode/core/location" +import { Job } from "@opencode/core/job" import { LocationActivity } from "@opencode/core/location-activity" import { LocationServiceMap } from "@opencode/core/location-services" import { Plugin } from "@opencode/core/plugin" @@ -49,7 +50,7 @@ const it = testEffect( layer: LocationActivity.layer({ timeToLive, sweepInterval: "100 millis" }).pipe( Layer.provide(Layer.succeed(Clock.Clock, activityClock)), ), - deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node], + deps: [Bus.node, Job.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node], }), ), ], diff --git a/packages/core/test/location-activity.test.ts b/packages/core/test/location-activity.test.ts index a71b0a82f45..256f169198e 100644 --- a/packages/core/test/location-activity.test.ts +++ b/packages/core/test/location-activity.test.ts @@ -8,6 +8,7 @@ import { makeGlobalNode } from "@opencode/util/effect/app-node" import { Bus } from "@opencode/core/bus" import { Database } from "@opencode/core/database/database" import { Form } from "@opencode/core/form" +import { Job } from "@opencode/core/job" import { Location } from "@opencode/core/location" import { LocationActivity } from "@opencode/core/location-activity" import { LocationServiceMap, type LocationServices } from "@opencode/core/location-services" @@ -84,6 +85,7 @@ const it = testEffect( LayerNode.group([ Database.node, Bus.node, + Job.node, SessionStore.node, LocationServiceMap.node, SessionExecution.node, @@ -102,121 +104,140 @@ const it = testEffect( ) describe("LocationActivity eviction", () => { - for (const [count, admission] of [ - [1, "none"], - [2, "none"], - [1, "other"], - [1, "same"], - ] as const) { - const newWork = admission !== "none" - it.effect( - `interrupts ${count} waiting executions before eviction (${admission} session admitted during cleanup)`, - () => - Effect.gen(function* () { - const db = (yield* Database.Service).db - const bus = yield* Bus.Service - const map = yield* LocationServiceMap.Service - const execution = yield* SessionExecution.Service - const store = yield* SessionStore.Service - const sessionIDs = Array.from({ length: count }, (_, index) => - Session.ID.make(`ses_waiting_question_${index}`), + for (const count of [1, 2]) { + it.effect(`keeps a location with ${count} executions waiting on a question until they go idle`, () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const bus = yield* Bus.Service + const map = yield* LocationServiceMap.Service + const execution = yield* SessionExecution.Service + const store = yield* SessionStore.Service + const sessionIDs = Array.from({ length: count }, (_, index) => Session.ID.make(`ses_waiting_question_${index}`)) + const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") }) + const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") }) + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values( + sessionIDs.map((sessionID) => ({ + id: sessionID, + project_id: Project.ID.global, + slug: "question", + directory: ref.directory, + title: "Waiting question", + version: "test", + })), ) - const newcomer = admission === "same" ? sessionIDs[0] : Session.ID.make("ses_new_question") - const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") }) - const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") }) - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] }) - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values( - Array.from(new Set([...sessionIDs, newcomer]), (sessionID) => ({ - id: sessionID, - project_id: Project.ID.global, - slug: "question", - directory: ref.directory, - title: "Waiting question", - version: "test", - })), - ) - .run() - .pipe(Effect.orDie) + .run() + .pipe(Effect.orDie) - const created = yield* Deferred.make() - const newCreated = yield* Deferred.make() - const pending: Form.Info[] = [] - const interrupted: SessionEvent.Execution.Interrupted["data"][] = [] - const unsubscribe = yield* bus.listen((event) => - Effect.gen(function* () { - if (event.type === SessionEvent.Execution.Interrupted.type) { - interrupted.push(Schema.decodeUnknownSync(SessionEvent.Execution.Interrupted.data)(event.data)) - } - if (event.type !== Form.Event.Created.type) return - pending.push(Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form) - if (pending.length === count) yield* Deferred.succeed(created, undefined) - if (pending.length > count) yield* Deferred.succeed(newCreated, undefined) - }), - ) - yield* Effect.addFinalizer(() => unsubscribe) - const running = yield* Effect.forEach(sessionIDs, (sessionID) => - execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped), - ) - yield* Effect.addFinalizer(() => - Effect.forEach([...sessionIDs, newcomer], (sessionID) => execution.interrupt(sessionID)).pipe( - Effect.andThen(TestClock.adjust("5 minutes")), - ), - ) - yield* Deferred.await(created) - const context = yield* map.contextEffect(ref).pipe(Effect.scoped) - const forms = Context.get(context, Form.Service) - expect((yield* store.listSuspended()).toSorted()).toEqual(sessionIDs.toSorted()) - yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped) + const created = yield* Deferred.make() + const pending: Form.Info[] = [] + const interrupted: SessionEvent.Execution.Interrupted["data"][] = [] + const unsubscribe = yield* bus.listen((event) => + Effect.gen(function* () { + if (event.type === SessionEvent.Execution.Interrupted.type) { + interrupted.push(Schema.decodeUnknownSync(SessionEvent.Execution.Interrupted.data)(event.data)) + } + if (event.type !== Form.Event.Created.type) return + pending.push(Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form) + if (pending.length === count) yield* Deferred.succeed(created, undefined) + }), + ) + yield* Effect.addFinalizer(() => unsubscribe) + const running = yield* Effect.forEach(sessionIDs, (sessionID) => + execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped), + ) + yield* Deferred.await(created) + const context = yield* map.contextEffect(ref).pipe(Effect.scoped) + const forms = Context.get(context, Form.Service) + yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped) - // Human input produces no durable activity while the question is pending. - yield* TestClock.adjust("1 minute") - yield* TestClock.adjust("62 minutes") - // Interruption has cancelled each question, but slow cleanup still owns the graph. - expect(Array.from(yield* execution.active).toSorted()).toEqual(sessionIDs.toSorted()) - expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref]) - expect(yield* forms.list()).toEqual([]) - for (const form of pending) expect(yield* forms.state(form.id)).toEqual({ status: "cancelled" }) + // Human input produces no durable activity while the question is pending. + yield* TestClock.adjust("1 minute") + yield* TestClock.adjust("62 minutes") + // The idle sibling expired in the same sweep; the waiting executions keep theirs. + expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref]) + expect(Array.from(yield* execution.active).toSorted()).toEqual(sessionIDs.toSorted()) + expect(interrupted).toEqual([]) + expect((yield* forms.list()).map((form) => form.id).toSorted()).toEqual( + pending.map((form) => form.id).toSorted(), + ) - if (newWork) { - yield* execution.wake(newcomer) - if (admission === "other") yield* Deferred.await(newCreated) - } - yield* TestClock.adjust("5 minutes") - if (newWork) yield* Deferred.await(newCreated) - const results = yield* Effect.forEach(running, Fiber.join) - expect(results.every((exit) => exit._tag === "Failure")).toBe(true) - expect(Array.from(yield* execution.active)).toEqual(newWork ? [newcomer] : []) - expect(yield* store.listSuspended()).toEqual(newWork ? [newcomer] : []) - expect(interrupted.toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual( - sessionIDs.map((sessionID) => ({ sessionID, reason: "inactivity" })), - ) - expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual(newWork ? [ref] : []) - if (newWork) { - expect(yield* forms.list({ sessionID: newcomer })).toEqual([pending[count]]) - if (admission === "same") { - const later = LocationServiceMap.canonical({ directory: AbsolutePath.make("/later") }) - yield* Location.Service.pipe(Effect.provide(map.get(later)), Effect.scoped) - yield* TestClock.adjust("30 minutes") - // Keep fresh work active while a different graph reaches its own deadline. - yield* bus.publish(SessionEvent.Execution.Started, { sessionID: newcomer }, { location: ref }) - yield* TestClock.adjust("32 minutes") - expect(Array.from(yield* execution.active)).toEqual([newcomer]) - expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref]) - } - yield* execution.interrupt(newcomer) - yield* TestClock.adjust("5 minutes") - yield* execution.awaitIdle(newcomer) - yield* TestClock.adjust("62 minutes") - expect(yield* store.listSuspended()).toEqual([]) - expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([]) - } - }), + yield* Effect.forEach(sessionIDs, (sessionID) => execution.interrupt(sessionID)) + yield* TestClock.adjust("5 minutes") + const results = yield* Effect.forEach(running, Fiber.join) + expect(results.every((exit) => exit._tag === "Failure")).toBe(true) + expect(yield* execution.active).toEqual(new Set()) + expect(yield* store.listSuspended()).toEqual([]) + yield* TestClock.adjust("62 minutes") + expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([]) + }), ) } + + for (const owner of ["shell", "subagent child", "subagent parent"] as const) + it.effect(`keeps a location with a running background job owned by its ${owner} until the job finishes`, () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const map = yield* LocationServiceMap.Service + const jobs = yield* Job.Service + const sessionID = Session.ID.make("ses_background_job") + // The other side of the subagent is unknown, so only the owner's location can keep the graph. + const missing = Session.ID.make("ses_missing") + const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") }) + const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") }) + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "background", + directory: ref.directory, + title: "Background job", + version: "test", + }) + .run() + .pipe(Effect.orDie) + yield* Location.Service.pipe(Effect.provide(map.get(ref)), Effect.scoped) + yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped) + const finish = yield* Deferred.make() + const job = yield* jobs.start({ + id: "job_background", + type: owner === "shell" ? "shell" : "subagent", + recovery: + owner === "shell" + ? { kind: "shell", sessionID, shellID: "shell_background", command: "sleep 9999" } + : { + kind: "subagent", + parentSessionID: owner === "subagent parent" ? sessionID : missing, + childSessionID: owner === "subagent child" ? sessionID : missing, + agent: "general", + description: "Background subagent", + }, + run: Deferred.await(finish), + }) + yield* jobs.background(job.id) + + yield* TestClock.adjust("1 minute") + yield* TestClock.adjust("62 minutes") + // The idle sibling expired in the same sweep; the job keeps its location. + expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref]) + expect((yield* jobs.get(job.id))?.status).toBe("running") + + yield* Deferred.succeed(finish, "done") + yield* jobs.wait({ id: job.id }) + yield* TestClock.adjust("62 minutes") + expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([]) + }), + ) }) From a62028dd2aa6c6fc1788dddb57bf6745fc2eb905 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Tue, 20 Oct 2026 17:50:03 +0200 Subject: [PATCH 04/98] feat(console): add hCaptcha to enterprise form (#57439) --- infra/console.ts | 5 + packages/console/app/src/i18n/ar.ts | 1 + packages/console/app/src/i18n/br.ts | 1 + packages/console/app/src/i18n/da.ts | 1 + packages/console/app/src/i18n/de.ts | 1 + packages/console/app/src/i18n/en.ts | 1 + packages/console/app/src/i18n/es.ts | 1 + packages/console/app/src/i18n/fr.ts | 1 + packages/console/app/src/i18n/it.ts | 1 + packages/console/app/src/i18n/ja.ts | 1 + packages/console/app/src/i18n/ko.ts | 1 + packages/console/app/src/i18n/no.ts | 1 + packages/console/app/src/i18n/pl.ts | 1 + packages/console/app/src/i18n/ru.ts | 1 + packages/console/app/src/i18n/th.ts | 1 + packages/console/app/src/i18n/tr.ts | 1 + packages/console/app/src/i18n/uk.ts | 1 + packages/console/app/src/i18n/zh.ts | 1 + packages/console/app/src/i18n/zht.ts | 1 + .../app/src/lib/enterprise-spam.test.ts | 3 +- packages/console/app/src/lib/enterprise.ts | 15 +++ packages/console/app/src/lib/hcaptcha.test.ts | 99 +++++++++++++++++++ packages/console/app/src/lib/hcaptcha.ts | 44 +++++++++ .../console/app/src/lib/salesforce.test.ts | 2 + .../app/src/routes/enterprise/index.tsx | 62 +++++++++++- 25 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 packages/console/app/src/lib/hcaptcha.test.ts create mode 100644 packages/console/app/src/lib/hcaptcha.ts diff --git a/infra/console.ts b/infra/console.ts index 8ddbc43f14a..d6663cbb3e7 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -24,6 +24,9 @@ const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL") // OpenCode API key for Jev spam scoring of enterprise form submissions. const ENTERPRISE_SPAM_API_KEY = new sst.Secret("ENTERPRISE_SPAM_API_KEY") +// hCaptcha guards the enterprise form; the site key is public and baked into the client bundle. +const HCAPTCHA_SITE_KEY = new sst.Secret("HCAPTCHA_SITE_KEY") +const HCAPTCHA_SECRET_KEY = new sst.Secret("HCAPTCHA_SECRET_KEY") const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID") const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET") @@ -43,6 +46,7 @@ new sst.cloudflare.x.SolidStart("Console", { AWS_SES_SECRET_ACCESS_KEY, ENTERPRISE_SALES_INBOX_EMAIL, ENTERPRISE_SPAM_API_KEY, + HCAPTCHA_SECRET_KEY, SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET, SALESFORCE_INSTANCE_URL, @@ -53,6 +57,7 @@ new sst.cloudflare.x.SolidStart("Console", { //VITE_DOCS_URL: web.url.apply((url) => url!), //VITE_API_URL: gateway.url.apply((url) => url!), VITE_CONSOLE_MARKETING_ENABLED: $app.stage === "dev" ? "true" : "false", + VITE_HCAPTCHA_SITE_KEY: HCAPTCHA_SITE_KEY.value, }, transform: { server: { diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index f7f0d7ab8ad..d0de9f57608 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -360,6 +360,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "تنسيق البريد الإلكتروني غير صالح.", "enterprise.form.error.invalidInferenceSpend": "اختر نطاقًا صالحًا للإنفاق على الاستدلال.", "enterprise.form.error.internalServer": "خطأ داخلي في الخادم.", + "enterprise.form.error.captcha": "فشل التحقق من captcha. يرجى المحاولة مرة أخرى.", "enterprise.faq.title": "الأسئلة الشائعة", "enterprise.faq.q1": "ما هو OpenCode Enterprise؟", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 3974d8b83c6..716a410c7d2 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -369,6 +369,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Formato de e-mail inválido.", "enterprise.form.error.invalidInferenceSpend": "Selecione uma faixa de gasto com inferência válida.", "enterprise.form.error.internalServer": "Erro interno do servidor.", + "enterprise.form.error.captcha": "A verificação captcha falhou. Tente novamente.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "O que é OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 90b85a70039..33c6810057a 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -366,6 +366,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Ugyldigt e-mailformat.", "enterprise.form.error.invalidInferenceSpend": "Vælg et gyldigt interval for inferensforbrug.", "enterprise.form.error.internalServer": "Intern serverfejl.", + "enterprise.form.error.captcha": "Captcha-verificering mislykkedes. Prøv igen.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Hvad er OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 26b0d006607..f80cb7db578 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -370,6 +370,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Ungültiges E-Mail-Format.", "enterprise.form.error.invalidInferenceSpend": "Wählen Sie einen gültigen Bereich für die Inferenzausgaben.", "enterprise.form.error.internalServer": "Interner Serverfehler.", + "enterprise.form.error.captcha": "Captcha-Überprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Was ist OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 0f28b12fde2..4de55091bdd 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -364,6 +364,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Invalid email format.", "enterprise.form.error.invalidInferenceSpend": "Select a valid inference spend range.", "enterprise.form.error.internalServer": "Internal server error.", + "enterprise.form.error.captcha": "Captcha verification failed. Please try again.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "What is OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 3748a8e64be..4f6c68048f5 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -370,6 +370,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Formato de correo inválido.", "enterprise.form.error.invalidInferenceSpend": "Selecciona un rango de gasto en inferencia válido.", "enterprise.form.error.internalServer": "Error interno del servidor.", + "enterprise.form.error.captcha": "La verificación captcha falló. Inténtalo de nuevo.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "¿Qué es OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 59ed7abaf37..88404933501 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -373,6 +373,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Format d'e-mail invalide.", "enterprise.form.error.invalidInferenceSpend": "Sélectionnez une tranche de dépenses d'inférence valide.", "enterprise.form.error.internalServer": "Erreur interne du serveur.", + "enterprise.form.error.captcha": "La vérification captcha a échoué. Veuillez réessayer.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Qu'est-ce que OpenCode Enterprise ?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 5977aec9f78..cd80f5f334a 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -366,6 +366,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Formato email non valido.", "enterprise.form.error.invalidInferenceSpend": "Seleziona una fascia di spesa per l'inferenza valida.", "enterprise.form.error.internalServer": "Errore interno del server.", + "enterprise.form.error.captcha": "Verifica captcha non riuscita. Riprova.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Cos'è OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 39e211e54ea..eb901e3d6d0 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -364,6 +364,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "無効なメール形式です。", "enterprise.form.error.invalidInferenceSpend": "有効な推論費用の範囲を選択してください。", "enterprise.form.error.internalServer": "内部サーバーエラー。", + "enterprise.form.error.captcha": "CAPTCHA認証に失敗しました。もう一度お試しください。", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "OpenCode Enterpriseとは?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 6c67c2bea66..5acc290b3b3 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -357,6 +357,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "유효하지 않은 이메일 형식입니다.", "enterprise.form.error.invalidInferenceSpend": "유효한 추론 비용 범위를 선택하세요.", "enterprise.form.error.internalServer": "내부 서버 오류입니다.", + "enterprise.form.error.captcha": "캡차 인증에 실패했습니다. 다시 시도해 주세요.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "OpenCode 엔터프라이즈란 무엇인가요?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 87c96ac40f3..4510dddc941 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -367,6 +367,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Ugyldig e-postformat.", "enterprise.form.error.invalidInferenceSpend": "Velg et gyldig intervall for inferensforbruk.", "enterprise.form.error.internalServer": "Intern serverfeil.", + "enterprise.form.error.captcha": "Captcha-verifiseringen mislyktes. Prøv igjen.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Hva er OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index a651a54d9fe..29c6a1b5aaa 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -368,6 +368,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Nieprawidłowy format adresu e-mail.", "enterprise.form.error.invalidInferenceSpend": "Wybierz prawidłowy przedział wydatków na inferencję.", "enterprise.form.error.internalServer": "Wewnętrzny błąd serwera.", + "enterprise.form.error.captcha": "Weryfikacja captcha nie powiodła się. Spróbuj ponownie.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Czym jest OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 3bb97fe3843..254e2c175ac 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -371,6 +371,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Неверный формат email.", "enterprise.form.error.invalidInferenceSpend": "Выберите допустимый диапазон расходов на инференс.", "enterprise.form.error.internalServer": "Внутренняя ошибка сервера.", + "enterprise.form.error.captcha": "Не удалось пройти проверку captcha. Попробуйте ещё раз.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Что такое OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 2979735d253..11955c8e7f1 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -363,6 +363,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "รูปแบบอีเมลไม่ถูกต้อง", "enterprise.form.error.invalidInferenceSpend": "เลือกช่วงค่าใช้จ่ายด้าน inference ที่ถูกต้อง", "enterprise.form.error.internalServer": "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", + "enterprise.form.error.captcha": "การยืนยัน captcha ล้มเหลว โปรดลองอีกครั้ง", "enterprise.faq.title": "คำถามที่พบบ่อย", "enterprise.faq.q1": "OpenCode Enterprise คืออะไร?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 3ad1406e298..eb50cced01e 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -370,6 +370,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Geçersiz e-posta formatı.", "enterprise.form.error.invalidInferenceSpend": "Geçerli bir çıkarım harcaması aralığı seçin.", "enterprise.form.error.internalServer": "İç sunucu hatası.", + "enterprise.form.error.captcha": "Captcha doğrulaması başarısız oldu. Lütfen tekrar deneyin.", "enterprise.faq.title": "SSS", "enterprise.faq.q1": "OpenCode Enterprise nedir?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 18f0eb016ee..98b36ac18dc 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -366,6 +366,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "Недійсний формат email.", "enterprise.form.error.invalidInferenceSpend": "Виберіть допустимий діапазон витрат на інференс.", "enterprise.form.error.internalServer": "Внутрішня помилка сервера.", + "enterprise.form.error.captcha": "Не вдалося пройти перевірку captcha. Спробуйте ще раз.", "enterprise.faq.title": "FAQ", "enterprise.faq.q1": "Що таке OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 35e27cc78cc..91d1b11376a 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -348,6 +348,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "邮箱格式无效。", "enterprise.form.error.invalidInferenceSpend": "请选择有效的推理支出范围。", "enterprise.form.error.internalServer": "内部服务器错误。", + "enterprise.form.error.captcha": "验证码验证失败,请重试。", "enterprise.faq.title": "常见问题", "enterprise.faq.q1": "什么是 OpenCode 企业版?", "enterprise.faq.a1": diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index d15cbab40f8..8c57bed0dd4 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -346,6 +346,7 @@ export const dict = { "enterprise.form.error.invalidEmailFormat": "無效的電子郵件格式。", "enterprise.form.error.invalidInferenceSpend": "請選擇有效的推論支出範圍。", "enterprise.form.error.internalServer": "內部伺服器錯誤。", + "enterprise.form.error.captcha": "驗證碼驗證失敗,請再試一次。", "enterprise.faq.title": "常見問題", "enterprise.faq.q1": "什麼是 OpenCode Enterprise?", "enterprise.faq.a1": diff --git a/packages/console/app/src/lib/enterprise-spam.test.ts b/packages/console/app/src/lib/enterprise-spam.test.ts index 75785b0da9b..0806beb630e 100644 --- a/packages/console/app/src/lib/enterprise-spam.test.ts +++ b/packages/console/app/src/lib/enterprise-spam.test.ts @@ -71,10 +71,11 @@ const submit = async (fetch: (request: Request) => Promise) => { new Request("http://localhost/api/enterprise", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...submission, alias: "" }), + body: JSON.stringify({ ...submission, alias: "", captcha: "test-token" }), }), { spam: { inferenceUrl: "https://opencode.test/inference", apiKey: "test-key", fetch }, + captcha: { secret: "test-secret", fetch: async () => Response.json({ success: true }) }, salesforceSubmit: async (payload) => { leads.push(payload) return true diff --git a/packages/console/app/src/lib/enterprise.ts b/packages/console/app/src/lib/enterprise.ts index ba26d5f6639..1652813a35e 100644 --- a/packages/console/app/src/lib/enterprise.ts +++ b/packages/console/app/src/lib/enterprise.ts @@ -6,6 +6,7 @@ import { createLead } from "~/lib/salesforce" import { inferenceSpendOptions } from "~/lib/inference-spend" import { lookupConsoleAccount, type ConsoleAccount } from "~/lib/console-account" import { ENTERPRISE_SPAM_THRESHOLD, enterpriseSpamProbability } from "~/lib/enterprise-spam" +import { verifyHcaptcha } from "~/lib/hcaptcha" interface EnterpriseFormData { name: string @@ -16,6 +17,7 @@ interface EnterpriseFormData { inferenceSpend?: string alias?: string message: string + captcha?: string } const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43" @@ -98,6 +100,7 @@ export async function handleEnterpriseRequest( notifications?: boolean accountLookup?: (email: string) => Promise spam?: Parameters[1] + captcha?: Parameters[1] } = {}, ) { const dict = i18n(localeFromRequest(request)) @@ -123,6 +126,18 @@ export async function handleEnterpriseRequest( return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 }) } + if (typeof body.captcha !== "string" || !body.captcha) { + return Response.json({ error: dict["enterprise.form.error.captcha"] }, { status: 400 }) + } + // Fail open when hCaptcha itself is unavailable; only a rejected token blocks the lead. + const human = await verifyHcaptcha(body.captcha, { + remoteip: request.headers.get("cf-connecting-ip") ?? undefined, + ...options.captcha, + }) + if (human === false) { + return Response.json({ error: dict["enterprise.form.error.captcha"] }, { status: 400 }) + } + // Fail open: a missing score never blocks a lead. Confident spam gets the same // response as the honeypot and never reaches Salesforce, email, or EmailOctopus. const spam = await enterpriseSpamProbability(body, options.spam) diff --git a/packages/console/app/src/lib/hcaptcha.test.ts b/packages/console/app/src/lib/hcaptcha.test.ts new file mode 100644 index 00000000000..c11601efb8c --- /dev/null +++ b/packages/console/app/src/lib/hcaptcha.test.ts @@ -0,0 +1,99 @@ +import { afterEach, expect, mock, spyOn, test } from "bun:test" +import { verifyHcaptcha } from "./hcaptcha" +import { handleEnterpriseRequest } from "./enterprise" + +afterEach(() => mock.restore()) + +test("verifies a token with hCaptcha siteverify", async () => { + const requests: Request[] = [] + const result = await verifyHcaptcha("token-123", { + secret: "test-secret", + remoteip: "203.0.113.7", + fetch: async (request) => { + requests.push(request) + return Response.json({ success: true }) + }, + }) + + expect(result).toBe(true) + expect(requests[0].url).toBe("https://api.hcaptcha.com/siteverify") + const body = new URLSearchParams(await requests[0].text()) + expect(body.get("secret")).toBe("test-secret") + expect(body.get("response")).toBe("token-123") + expect(body.get("remoteip")).toBe("203.0.113.7") +}) + +test("rejects tokens hCaptcha says are invalid", async () => { + spyOn(console, "warn").mockImplementation(() => {}) + const result = await verifyHcaptcha("bad", { + secret: "test-secret", + fetch: async () => Response.json({ success: false, "error-codes": ["invalid-input-response"] }), + }) + expect(result).toBe(false) +}) + +test("returns undefined when hCaptcha is unreachable or misconfigured", async () => { + spyOn(console, "error").mockImplementation(() => {}) + const failures = [ + async () => new Response("down", { status: 503 }), + async () => { + throw new Error("network") + }, + async () => Response.json({ success: false, "error-codes": ["invalid-input-secret"] }), + (request: Request) => + new Promise((_, reject) => + request.signal.addEventListener("abort", () => reject(request.signal.reason)), + ), + ] + for (const fetch of failures) { + expect(await verifyHcaptcha("token", { secret: "test-secret", timeoutMs: 20, fetch })).toBeUndefined() + } +}) + +const submit = async (captcha: string | undefined, verify: () => Promise) => { + const leads: Record[] = [] + const response = await handleEnterpriseRequest( + new Request("http://localhost/api/enterprise", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Fake Visitor", + role: "Engineering lead", + email: "fake-visitor@example.test", + message: "We need SSO for 200 developers.", + captcha, + }), + }), + { + captcha: { secret: "test-secret", fetch: verify }, + spam: { inferenceUrl: "" }, + salesforceSubmit: async (payload) => { + leads.push(payload) + return true + }, + notifications: false, + accountLookup: async () => null, + }, + ) + return { status: response.status, leads } +} + +test("the enterprise form rejects missing or invalid captcha tokens before creating a lead", async () => { + spyOn(console, "warn").mockImplementation(() => {}) + const verified: string[] = [] + const missing = await submit(undefined, async () => { + verified.push("called") + return Response.json({ success: true }) + }) + expect(missing).toEqual({ status: 400, leads: [] }) + expect(verified).toHaveLength(0) + + const invalid = await submit("bad", async () => Response.json({ success: false })) + expect(invalid).toEqual({ status: 400, leads: [] }) +}) + +test("the enterprise form accepts verified tokens and fails open when hCaptcha is down", async () => { + spyOn(console, "error").mockImplementation(() => {}) + expect((await submit("good", async () => Response.json({ success: true }))).leads).toHaveLength(1) + expect((await submit("good", async () => new Response("down", { status: 503 }))).leads).toHaveLength(1) +}) diff --git a/packages/console/app/src/lib/hcaptcha.ts b/packages/console/app/src/lib/hcaptcha.ts new file mode 100644 index 00000000000..bc9a62cc044 --- /dev/null +++ b/packages/console/app/src/lib/hcaptcha.ts @@ -0,0 +1,44 @@ +import { Resource } from "@opencode/console-resource" + +const TIMEOUT_MS = 3000 +const CONFIG_ERRORS = new Set(["missing-input-secret", "invalid-input-secret", "sitekey-secret-mismatch"]) + +// Returns undefined when hCaptcha is unreachable so the caller can fail open. +export async function verifyHcaptcha( + token: string, + options: { + fetch?: (request: Request) => Promise + secret?: string + remoteip?: string + timeoutMs?: number + } = {}, +): Promise { + try { + const body = new URLSearchParams({ secret: options.secret ?? Resource.HCAPTCHA_SECRET_KEY.value, response: token }) + if (options.remoteip) body.set("remoteip", options.remoteip) + const response = await (options.fetch ?? fetch)( + new Request("https://api.hcaptcha.com/siteverify", { + method: "POST", + body, + signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS), + }), + ) + if (!response.ok) { + console.error("hCaptcha verification failed:", response.status) + return undefined + } + const data = (await response.json()) as { success?: unknown; "error-codes"?: unknown } + if (data.success === true) return true + const errors = Array.isArray(data["error-codes"]) ? data["error-codes"] : [] + // A broken secret or site key is our misconfiguration, not a bot, so it fails open too. + if (errors.some((code) => CONFIG_ERRORS.has(code))) { + console.error("hCaptcha is misconfigured:", errors) + return undefined + } + console.warn("hCaptcha rejected token", { errors }) + return false + } catch (error) { + console.error("hCaptcha verification unavailable:", error instanceof Error ? error.name : "unknown") + return undefined + } +} diff --git a/packages/console/app/src/lib/salesforce.test.ts b/packages/console/app/src/lib/salesforce.test.ts index 32c9f6274c6..36cf5419e84 100644 --- a/packages/console/app/src/lib/salesforce.test.ts +++ b/packages/console/app/src/lib/salesforce.test.ts @@ -77,9 +77,11 @@ test("the local Enterprise request handler applies the updated mapping to a form inferenceSpend: "50k-100k", message: " How are you bro!\nThis is a local form test. ", alias: "", + captcha: "test-token", }), }), { + captcha: { secret: "test-secret", fetch: async () => Response.json({ success: true }) }, salesforceSubmit: async (payload) => { const response = await fetch(`${server.url.origin}/services/data/v59.0/sobjects/Lead`, { method: "POST", diff --git a/packages/console/app/src/routes/enterprise/index.tsx b/packages/console/app/src/routes/enterprise/index.tsx index 5def8f208bd..588019cf76b 100644 --- a/packages/console/app/src/routes/enterprise/index.tsx +++ b/packages/console/app/src/routes/enterprise/index.tsx @@ -1,6 +1,6 @@ import "./index.css" import { Title, Meta } from "@solidjs/meta" -import { createSignal, For, Show } from "solid-js" +import { createSignal, For, onMount, Show } from "solid-js" import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" @@ -10,8 +10,43 @@ import { LocaleLinks } from "~/component/locale-links" import { SocialMeta } from "~/component/social-meta" import { inferenceSpendOptions } from "~/lib/inference-spend" +type HCaptcha = { + render(container: HTMLElement, params: { sitekey: string; size: "invisible" }): string + execute(widget: string, options: { async: true }): Promise<{ response: string }> + reset(widget: string): void +} + +declare global { + interface Window { + hcaptcha?: HCaptcha + onEnterpriseHcaptchaLoad?: () => void + } +} + +function loadHcaptcha(container: HTMLElement) { + const sitekey = import.meta.env.VITE_HCAPTCHA_SITE_KEY + if (!sitekey) return Promise.reject(new Error("VITE_HCAPTCHA_SITE_KEY is not set")) + return new Promise<{ api: HCaptcha; widget: string }>((resolve, reject) => { + window.onEnterpriseHcaptchaLoad = () => { + const api = window.hcaptcha! + resolve({ api, widget: api.render(container, { sitekey, size: "invisible" }) }) + } + const script = document.createElement("script") + script.src = "https://js.hcaptcha.com/1/api.js?render=explicit&onload=onEnterpriseHcaptchaLoad" + script.async = true + script.onerror = () => reject(new Error("hCaptcha failed to load")) + document.head.appendChild(script) + }) +} + export default function Enterprise() { const i18n = useI18n() + let captchaContainer!: HTMLDivElement + let captcha: ReturnType | undefined + onMount(() => { + captcha = loadHcaptcha(captchaContainer) + captcha.catch((error) => console.error(error)) + }) const [formData, setFormData] = createSignal({ name: "", role: "", @@ -37,13 +72,28 @@ export default function Enterprise() { setShowSuccess(false) setIsSubmitting(true) + const token = await captcha + ?.then((hcaptcha) => + hcaptcha.api.execute(hcaptcha.widget, { async: true }).finally(() => hcaptcha.api.reset(hcaptcha.widget)), + ) + .then((result) => result.response) + .catch((error) => { + console.error("hCaptcha challenge failed:", error) + return undefined + }) + if (!token) { + setError(i18n.t("enterprise.form.error.captcha")) + setIsSubmitting(false) + return + } + try { const response = await fetch("/api/enterprise", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(formData()), + body: JSON.stringify({ ...formData(), captcha: token }), }) if (response.ok) { @@ -77,7 +127,11 @@ export default function Enterprise() { {i18n.t("enterprise.title")} - +
@@ -265,6 +319,8 @@ export default function Enterprise() { />
+
+ From 5916014733c9f8cf9aaba5810c45d3092f74dd38 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Tue, 20 Oct 2026 18:41:30 +0200 Subject: [PATCH 05/98] refactor(console): remove Jev spam scoring from enterprise form (#57446) --- infra/console.ts | 3 - .../app/src/lib/enterprise-spam.test.ts | 156 ------------------ .../console/app/src/lib/enterprise-spam.ts | 75 --------- .../console/app/src/lib/enterprise.test.ts | 51 ++++++ packages/console/app/src/lib/enterprise.ts | 11 -- packages/console/app/src/lib/hcaptcha.test.ts | 1 - 6 files changed, 51 insertions(+), 246 deletions(-) delete mode 100644 packages/console/app/src/lib/enterprise-spam.test.ts delete mode 100644 packages/console/app/src/lib/enterprise-spam.ts create mode 100644 packages/console/app/src/lib/enterprise.test.ts diff --git a/infra/console.ts b/infra/console.ts index d6663cbb3e7..0b35732f229 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -22,8 +22,6 @@ new sst.cloudflare.Bucket("ZenDataNew") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL") -// OpenCode API key for Jev spam scoring of enterprise form submissions. -const ENTERPRISE_SPAM_API_KEY = new sst.Secret("ENTERPRISE_SPAM_API_KEY") // hCaptcha guards the enterprise form; the site key is public and baked into the client bundle. const HCAPTCHA_SITE_KEY = new sst.Secret("HCAPTCHA_SITE_KEY") const HCAPTCHA_SECRET_KEY = new sst.Secret("HCAPTCHA_SECRET_KEY") @@ -45,7 +43,6 @@ new sst.cloudflare.x.SolidStart("Console", { AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, ENTERPRISE_SALES_INBOX_EMAIL, - ENTERPRISE_SPAM_API_KEY, HCAPTCHA_SECRET_KEY, SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET, diff --git a/packages/console/app/src/lib/enterprise-spam.test.ts b/packages/console/app/src/lib/enterprise-spam.test.ts deleted file mode 100644 index 0806beb630e..00000000000 --- a/packages/console/app/src/lib/enterprise-spam.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { afterEach, expect, mock, spyOn, test } from "bun:test" -import { enterpriseSpamProbability, ENTERPRISE_SPAM_MODEL } from "./enterprise-spam" -import { enterpriseEmailContent, handleEnterpriseRequest } from "./enterprise" - -const submission = { - name: "Fake Visitor", - role: "Engineering lead", - company: "Example Test Company", - email: "fake-visitor@example.test", - message: "We need SSO for 200 developers.", -} - -afterEach(() => mock.restore()) - -const score = (probability: unknown) => - Response.json({ model: ENTERPRISE_SPAM_MODEL, answers: { spam: { type: "noul", noul: probability } } }) - -test("scores a submission through the System One gateway", async () => { - const requests: Request[] = [] - // The form handler passes the whole submission, phone included; the classifier must not receive it. - const withPhone = { ...submission, phone: "+1 555 0100" } - const probability = await enterpriseSpamProbability(withPhone, { - inferenceUrl: "https://opencode.test/inference", - apiKey: "test-key", - fetch: async (request) => { - requests.push(request) - return score(0.97) - }, - }) - - expect(probability).toBe(0.97) - expect(requests).toHaveLength(1) - expect(requests[0].url).toBe("https://opencode.test/inference/systemone/v1/systemone") - expect(requests[0].headers.get("authorization")).toBe("Bearer test-key") - const body = await requests[0].json() - expect(body.model).toBe(ENTERPRISE_SPAM_MODEL) - expect(body.state.submission).toMatchObject({ email: submission.email, message: submission.message }) - expect(body.state.submission).not.toHaveProperty("phone") - expect(body.questions.spam.type).toBe("noul") -}) - -test("returns no score when the gateway fails, times out, or answers without a probability", async () => { - const options = { inferenceUrl: "https://opencode.test/inference", apiKey: "test-key" } - const failures = [ - async () => new Response("nope", { status: 401 }), - async () => { - throw new Error("network") - }, - async () => score("high"), - async () => score(1.5), - async () => Response.json({ answers: {} }), - (request: Request) => - new Promise((_, reject) => - request.signal.addEventListener("abort", () => reject(request.signal.reason)), - ), - ] - for (const fetch of failures) { - expect(await enterpriseSpamProbability(submission, { ...options, timeoutMs: 20, fetch })).toBeUndefined() - } -}) - -test("returns no score without logging an error when the gateway is not configured", async () => { - const error = spyOn(console, "error").mockImplementation(() => {}) - expect(await enterpriseSpamProbability(submission, { inferenceUrl: "", apiKey: "test-key" })).toBeUndefined() - expect(error).not.toHaveBeenCalled() -}) - -const submit = async (fetch: (request: Request) => Promise) => { - const leads: Record[] = [] - const response = await handleEnterpriseRequest( - new Request("http://localhost/api/enterprise", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...submission, alias: "", captcha: "test-token" }), - }), - { - spam: { inferenceUrl: "https://opencode.test/inference", apiKey: "test-key", fetch }, - captcha: { secret: "test-secret", fetch: async () => Response.json({ success: true }) }, - salesforceSubmit: async (payload) => { - leads.push(payload) - return true - }, - notifications: false, - accountLookup: async () => null, - }, - ) - return { status: response.status, body: await response.json(), leads } -} - -test("confident spam gets the normal success response, never reaches Salesforce, and is logged", async () => { - const warn = spyOn(console, "warn").mockImplementation(() => {}) - const result = await submit(async () => score(0.95)) - expect(result.status).toBe(200) - expect(result.body).toMatchObject({ success: true }) - expect(result.leads).toHaveLength(0) - expect(warn).toHaveBeenCalledWith("Enterprise inquiry dropped as spam", { - email: submission.email, - probability: 0.95, - }) -}) - -test("uncertain, missing, or failed scores still create the lead", async () => { - spyOn(console, "error").mockImplementation(() => {}) - for (const fetch of [ - async () => score(0.6), - async () => Response.json({ answers: {} }), - async () => { - throw new Error("classifier down") - }, - ]) { - const result = await submit(fetch) - expect(result.status).toBe(200) - expect(result.leads).toHaveLength(1) - } -}) - -test("the sales inbox email escapes submitted HTML", () => { - const content = enterpriseEmailContent({ - name: "Name", - role: "Role", - email: "fake-visitor@example.test", - message: ` & `, - alias: "", - }) - expect(content.html).not.toContain(" & `, + alias: "", + }) + expect(content.html).not.toContain("