@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -11
- package/bin/cli-opencode.test.js +4 -4
- package/bin/cli.js +1 -1
- package/bin/lib/cli-startup.js +1 -1
- package/bin/lib/cli-startup.test.js +18 -4
- package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
- package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
- package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
- package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
- package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
- package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
- package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
- package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
- package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
- package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
- package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
- package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
- package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
- package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
- package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
- package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
- package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
- package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
- package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
- package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
- package/dist/assets/context-menu-B2LXJgXL.js +1 -0
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
- package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
- package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
- package/dist/assets/es-CN3E_zXj.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
- package/dist/assets/fr-9zZvQZD2.js +13 -0
- package/dist/assets/gitApi-yuWg9BN4.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
- package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
- package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
- package/dist/assets/ja-Dnjk_82N.js +15 -0
- package/dist/assets/ko-BxCEE99C.js +15 -0
- package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
- package/dist/assets/main-DiL6w6KF.js +169 -0
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
- package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
- package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
- package/dist/assets/miniChat-CwtUaElU.js +2 -0
- package/dist/assets/mobile-DB-6--ws.js +2 -0
- package/dist/assets/multirun-TAsxFI8e.js +4 -0
- package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
- package/dist/assets/pl-CNNsWnPM.js +15 -0
- package/dist/assets/platform-C13fMVEs.js +6 -0
- package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
- package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
- package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
- package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
- package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
- package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
- package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
- package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
- package/dist/assets/src-ubCWXZhR.css +2 -0
- package/dist/assets/store-DLCiZaA6.js +15 -0
- package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
- package/dist/assets/textarea-BBOlRI9O.js +1 -0
- package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
- package/dist/assets/uk-rwQrxeYM.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
- package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
- package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
- package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
- package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
- package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
- package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
- package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
- package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
- package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
- package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
- package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
- package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
- package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
- package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
- package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +1 -1
- package/server/index.js +327 -62
- package/server/lib/assistants/DOCUMENTATION.md +14 -9
- package/server/lib/assistants/contracts.js +11 -1
- package/server/lib/assistants/projection-history.test.js +265 -18
- package/server/lib/assistants/routes.js +6 -0
- package/server/lib/assistants/service.js +417 -231
- package/server/lib/assistants/service.test.js +392 -148
- package/server/lib/conversations/DOCUMENTATION.md +1 -1
- package/server/lib/conversations/service.js +1 -0
- package/server/lib/conversations/service.test.js +16 -0
- package/server/lib/conversations/validation.js +11 -1
- package/server/lib/event-stream/DOCUMENTATION.md +3 -1
- package/server/lib/event-stream/directory-ws-bridge.js +13 -2
- package/server/lib/event-stream/global-hub.js +49 -4
- package/server/lib/event-stream/reasoning-projection.js +16 -1
- package/server/lib/event-stream/reasoning-projection.test.js +15 -0
- package/server/lib/event-stream/runtime.js +57 -20
- package/server/lib/event-stream/runtime.test.js +112 -0
- package/server/lib/llm/DOCUMENTATION.md +33 -9
- package/server/lib/llm/catalog.js +1 -1
- package/server/lib/llm/completions.js +5 -1
- package/server/lib/llm/completions.test.js +3 -3
- package/server/lib/llm/generate.js +237 -145
- package/server/lib/llm/generate.test.js +201 -46
- package/server/lib/llm/routes.js +2 -0
- package/server/lib/message-queue/runtime.js +15 -2
- package/server/lib/notifications/runtime.js +23 -5
- package/server/lib/notifications/runtime.test.js +11 -0
- package/server/lib/opencode/DOCUMENTATION.md +85 -17
- package/server/lib/opencode/agent-document.d.ts +28 -0
- package/server/lib/opencode/agent-document.js +280 -0
- package/server/lib/opencode/agent-document.test.js +137 -0
- package/server/lib/opencode/agents.js +96 -0
- package/server/lib/opencode/auth-state-runtime.js +5 -0
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-entity-routes.js +42 -99
- package/server/lib/opencode/config-entity-routes.test.js +107 -0
- package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
- package/server/lib/opencode/ensure-cli.js +1 -2
- package/server/lib/opencode/env-runtime.js +40 -26
- package/server/lib/opencode/env-runtime.test.js +10 -17
- package/server/lib/opencode/feature-routes-runtime.js +43 -4
- package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
- package/server/lib/opencode/http-server-connections.js +98 -0
- package/server/lib/opencode/http-server-connections.test.js +321 -0
- package/server/lib/opencode/lifecycle.js +349 -39
- package/server/lib/opencode/lifecycle.test.js +322 -0
- package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
- package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
- package/server/lib/opencode/openchamber-routes.js +7 -1
- package/server/lib/opencode/opencode2-pin.js +4 -4
- package/server/lib/opencode/opencode2-pin.test.js +1 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
- package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
- package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
- package/server/lib/opencode/plugin-routes.js +3 -18
- package/server/lib/opencode/plugin-routes.test.js +9 -9
- package/server/lib/opencode/provider-catalog.js +58 -12
- package/server/lib/opencode/provider-catalog.test.js +48 -0
- package/server/lib/opencode/proxy.js +252 -46
- package/server/lib/opencode/proxy.test.js +235 -2
- package/server/lib/opencode/routes.js +296 -62
- package/server/lib/opencode/routes.test.js +278 -12
- package/server/lib/opencode/runtime-contract.js +401 -0
- package/server/lib/opencode/runtime-contract.test.js +214 -0
- package/server/lib/opencode/server-opencode-fetch.js +242 -0
- package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
- package/server/lib/opencode/server-utils-runtime.js +33 -0
- package/server/lib/opencode/shared-service.integration.test.js +66 -0
- package/server/lib/opencode/shared-service.js +147 -0
- package/server/lib/opencode/shared-service.test.js +93 -0
- package/server/lib/opencode/shared.js +3 -3
- package/server/lib/opencode/shutdown-runtime.js +15 -6
- package/server/lib/opencode/shutdown-runtime.test.js +49 -1
- package/server/lib/opencode/skill-routes.js +20 -33
- package/server/lib/opencode/skill-routes.test.js +35 -2
- package/server/lib/opencode/skills.test.js +15 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
- package/server/lib/opencode/static-routes-runtime.js +3 -1
- package/server/lib/opencode/static-routes-runtime.test.js +33 -0
- package/server/lib/opencode/v2-client.js +14 -3
- package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
- package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
- package/server/lib/permission-auto-accept/runtime.js +14 -7
- package/server/lib/permission-auto-accept/runtime.test.js +27 -12
- package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
- package/server/lib/scheduled-tasks/runtime.js +52 -4
- package/server/lib/scheduled-tasks/runtime.test.js +80 -1
- package/server/lib/session-goal/DOCUMENTATION.md +113 -13
- package/server/lib/session-goal/routes.js +9 -0
- package/server/lib/session-goal/runtime.js +1579 -117
- package/server/lib/session-goal/runtime.test.js +1453 -84
- package/server/lib/session-index/DOCUMENTATION.md +18 -15
- package/server/lib/session-index/event-ingest.js +89 -16
- package/server/lib/session-index/event-ingest.test.js +73 -0
- package/server/lib/session-index/host-authority-snapshot.js +92 -0
- package/server/lib/session-index/routes.host-archive.test.js +201 -0
- package/server/lib/session-index/routes.js +63 -10
- package/server/lib/session-index/routes.test.js +8 -8
- package/server/lib/session-index/service.js +7 -1
- package/server/lib/session-index/sync-runtime.js +161 -34
- package/server/lib/session-index/sync-runtime.test.js +155 -1
- package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
- package/server/lib/session-metadata/routes.js +108 -9
- package/server/lib/session-metadata/routes.test.js +47 -2
- package/server/lib/session-metadata/session-archive.d.ts +73 -0
- package/server/lib/session-metadata/session-archive.js +654 -0
- package/server/lib/session-metadata/session-archive.test.js +359 -0
- package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
- package/server/lib/session-metadata/session-metadata-store.js +212 -72
- package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
- package/server/lib/session-metadata/session-projection.d.ts +41 -0
- package/server/lib/session-metadata/session-projection.js +255 -0
- package/server/lib/session-metadata/session-projection.test.js +64 -0
- package/server/lib/session-metadata/system-session.js +55 -0
- package/server/lib/session-metadata/system-session.test.js +55 -0
- package/server/lib/session-title/DOCUMENTATION.md +40 -7
- package/server/lib/session-title/native-runtime.test.js +167 -0
- package/server/lib/session-title/runtime.js +67 -42
- package/server/lib/session-title/session-access.js +46 -0
- package/server/lib/session-title/session-access.test.js +91 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
- package/server/lib/session-turn-pages/routes.js +17 -102
- package/server/lib/session-turn-pages/routes.test.js +281 -0
- package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
- package/server/lib/session-turn-pages/session-message-projection.js +281 -0
- package/server/lib/small-model/DOCUMENTATION.md +101 -147
- package/server/lib/small-model/catalog.js +31 -51
- package/server/lib/small-model/catalog.test.js +65 -10
- package/server/lib/small-model/custom-api.js +68 -0
- package/server/lib/small-model/custom-api.test.js +64 -0
- package/server/lib/small-model/index.js +124 -240
- package/server/lib/small-model/resolve.js +35 -120
- package/server/lib/small-model/resolve.test.js +20 -110
- package/server/lib/small-model/routes.js +1 -3
- package/server/lib/small-model/summary-settings.test.js +157 -253
- package/server/opencode-proxy.test.js +2 -0
- package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
- package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
- package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
- package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
- package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
- package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
- package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
- package/dist/assets/context-menu-jGtH6fR9.js +0 -1
- package/dist/assets/es-c52LTHHe.js +0 -15
- package/dist/assets/fr-PisFbUE8.js +0 -13
- package/dist/assets/gitApi-ButwzQ0N.js +0 -9
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
- package/dist/assets/ja-C8Ikun3Z.js +0 -15
- package/dist/assets/ko-ClFys5o2.js +0 -15
- package/dist/assets/main-Dn9rpsgl.js +0 -169
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/miniChat-BpjxVqgg.js +0 -2
- package/dist/assets/mobile-nMJCaNq1.js +0 -2
- package/dist/assets/multirun-BvSG94mW.js +0 -4
- package/dist/assets/pl-CnaKWN5F.js +0 -15
- package/dist/assets/platform-CWTIp02q.js +0 -4
- package/dist/assets/projectMeta-DOe49dM_.js +0 -3
- package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
- package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
- package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
- package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
- package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
- package/dist/assets/src-BzX5UI-c.css +0 -2
- package/dist/assets/store-DJczmczv.js +0 -15
- package/dist/assets/textarea-3XR8sUfy.js +0 -1
- package/dist/assets/uk-CaX0iqkB.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
- package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
- package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
- package/dist/assets/zh-CN-20MYEjha.js +0 -15
- package/dist/assets/zh-TW-DiN04qTw.js +0 -15
- package/server/lib/small-model/call.js +0 -427
- package/server/lib/small-model/call.test.js +0 -382
- package/server/lib/small-model/opencode-session.js +0 -183
- package/server/lib/small-model/opencode-session.test.js +0 -147
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side OpenCode transport boundary (ticket 11).
|
|
3
|
+
*
|
|
4
|
+
* Proxy gates browser → OpenChamber → OpenCode. Background Host work
|
|
5
|
+
* (queue / goal / scheduled / assistants / session-title) often calls OpenCode
|
|
6
|
+
* directly and must share the same execution admission: verified contract only
|
|
7
|
+
* for writes; diagnostics, reads, and stop-task stay available.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
runtimeContractExecutionBlockedBody,
|
|
12
|
+
shouldBlockRuntimeContractExecution,
|
|
13
|
+
} from './runtime-contract.js';
|
|
14
|
+
|
|
15
|
+
/** @type {(() => (object | null | undefined)) | null} */
|
|
16
|
+
let registeredGetRuntimeContract = null;
|
|
17
|
+
|
|
18
|
+
/** @type {{ allowMissingContract?: boolean }} */
|
|
19
|
+
let registeredGateOptions = {};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wire once from the composition root so makeOpenCodeV2Client + serverOpenCodeFetch
|
|
23
|
+
* share the live lifecycle contract without each caller threading deps.
|
|
24
|
+
*
|
|
25
|
+
* Production must not pass `allowMissingContract: true` — missing contract blocks
|
|
26
|
+
* writes. Unit/DI factories may opt in when no lifecycle is configured.
|
|
27
|
+
* @param {(() => (object | null | undefined)) | null} getter
|
|
28
|
+
* @param {{ allowMissingContract?: boolean }} [options]
|
|
29
|
+
*/
|
|
30
|
+
export function configureServerOpenCodeFetchGate(getter, options = {}) {
|
|
31
|
+
registeredGetRuntimeContract = typeof getter === 'function' ? getter : null;
|
|
32
|
+
registeredGateOptions = {
|
|
33
|
+
allowMissingContract: options?.allowMissingContract === true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getRegisteredRuntimeContract() {
|
|
38
|
+
try {
|
|
39
|
+
return registeredGetRuntimeContract ? registeredGetRuntimeContract() : null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getRegisteredRuntimeContractGateOptions() {
|
|
46
|
+
return { ...registeredGateOptions };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string | URL | Request} input
|
|
51
|
+
* @param {RequestInit | undefined} init
|
|
52
|
+
* @returns {{ method: string, pathWithQuery: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveFetchMethodAndPath(input, init) {
|
|
55
|
+
const method = String(
|
|
56
|
+
(init && typeof init.method === 'string' && init.method)
|
|
57
|
+
|| (typeof Request !== 'undefined' && input instanceof Request && input.method)
|
|
58
|
+
|| 'GET',
|
|
59
|
+
).toUpperCase();
|
|
60
|
+
|
|
61
|
+
let urlString = '';
|
|
62
|
+
if (typeof input === 'string') {
|
|
63
|
+
urlString = input;
|
|
64
|
+
} else if (typeof URL !== 'undefined' && input instanceof URL) {
|
|
65
|
+
urlString = input.toString();
|
|
66
|
+
} else if (typeof Request !== 'undefined' && input instanceof Request) {
|
|
67
|
+
urlString = input.url;
|
|
68
|
+
} else {
|
|
69
|
+
urlString = String(input || '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let pathWithQuery = urlString;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(urlString, 'http://opencode.local');
|
|
75
|
+
pathWithQuery = `${parsed.pathname}${parsed.search || ''}`;
|
|
76
|
+
} catch {
|
|
77
|
+
const stripped = urlString.replace(/^https?:\/\/[^/]+/i, '');
|
|
78
|
+
pathWithQuery = stripped.startsWith('/') ? stripped : `/${stripped}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { method, pathWithQuery };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {{
|
|
86
|
+
* getRuntimeContract?: (() => (object | null | undefined)) | null,
|
|
87
|
+
* method: string,
|
|
88
|
+
* pathWithQuery: string,
|
|
89
|
+
* allowMissingContract?: boolean,
|
|
90
|
+
* }} input
|
|
91
|
+
*/
|
|
92
|
+
export function assertServerOpenCodeExecutionAllowed(input) {
|
|
93
|
+
const getter = input.getRuntimeContract || registeredGetRuntimeContract;
|
|
94
|
+
let contract = null;
|
|
95
|
+
try {
|
|
96
|
+
contract = typeof getter === 'function' ? getter() : null;
|
|
97
|
+
} catch {
|
|
98
|
+
contract = null;
|
|
99
|
+
}
|
|
100
|
+
const allowMissingContract = typeof input.allowMissingContract === 'boolean'
|
|
101
|
+
? input.allowMissingContract
|
|
102
|
+
: registeredGateOptions.allowMissingContract === true;
|
|
103
|
+
if (!shouldBlockRuntimeContractExecution(input.method, input.pathWithQuery, contract, {
|
|
104
|
+
allowMissingContract,
|
|
105
|
+
})) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return runtimeContractExecutionBlockedBody(contract);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Wrap fetch so host OpenCode SDK clients share proxy write admission.
|
|
113
|
+
* @param {typeof fetch} [fetchImpl]
|
|
114
|
+
* @param {{
|
|
115
|
+
* getRuntimeContract?: () => (object | null | undefined),
|
|
116
|
+
* allowMissingContract?: boolean,
|
|
117
|
+
* }} [options]
|
|
118
|
+
* @returns {typeof fetch}
|
|
119
|
+
*/
|
|
120
|
+
export function wrapFetchWithRuntimeContractGate(fetchImpl = fetch, options = {}) {
|
|
121
|
+
const getRuntimeContract = options.getRuntimeContract || (() => getRegisteredRuntimeContract());
|
|
122
|
+
const allowMissingContract = typeof options.allowMissingContract === 'boolean'
|
|
123
|
+
? options.allowMissingContract
|
|
124
|
+
: registeredGateOptions.allowMissingContract === true;
|
|
125
|
+
|
|
126
|
+
return async (input, init) => {
|
|
127
|
+
const { method, pathWithQuery } = resolveFetchMethodAndPath(input, init);
|
|
128
|
+
const blocked = assertServerOpenCodeExecutionAllowed({
|
|
129
|
+
getRuntimeContract,
|
|
130
|
+
method,
|
|
131
|
+
pathWithQuery,
|
|
132
|
+
allowMissingContract,
|
|
133
|
+
});
|
|
134
|
+
if (blocked) {
|
|
135
|
+
return new Response(JSON.stringify(blocked), {
|
|
136
|
+
status: 409,
|
|
137
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return fetchImpl(input, init);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* High-level Host OpenCode JSON helper used by goal / title and tests.
|
|
146
|
+
* @param {{
|
|
147
|
+
* buildOpenCodeUrl: (pathname: string, prefix?: string) => string,
|
|
148
|
+
* getOpenCodeAuthHeaders: () => Record<string, string>,
|
|
149
|
+
* getRuntimeContract?: () => (object | null | undefined),
|
|
150
|
+
* allowMissingContract?: boolean,
|
|
151
|
+
* fetchImpl?: typeof fetch,
|
|
152
|
+
* }} deps
|
|
153
|
+
*/
|
|
154
|
+
export function createServerOpenCodeFetch(deps) {
|
|
155
|
+
const {
|
|
156
|
+
buildOpenCodeUrl,
|
|
157
|
+
getOpenCodeAuthHeaders,
|
|
158
|
+
getRuntimeContract = () => getRegisteredRuntimeContract(),
|
|
159
|
+
allowMissingContract,
|
|
160
|
+
fetchImpl = fetch,
|
|
161
|
+
} = deps;
|
|
162
|
+
|
|
163
|
+
const gatedFetch = wrapFetchWithRuntimeContractGate(fetchImpl, {
|
|
164
|
+
getRuntimeContract,
|
|
165
|
+
allowMissingContract,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} fetchPath
|
|
170
|
+
* @param {{
|
|
171
|
+
* directory?: string | null,
|
|
172
|
+
* method?: string,
|
|
173
|
+
* body?: unknown,
|
|
174
|
+
* query?: Record<string, string> | URLSearchParams | null,
|
|
175
|
+
* signal?: AbortSignal,
|
|
176
|
+
* timeoutMs?: number,
|
|
177
|
+
* }} [options]
|
|
178
|
+
*/
|
|
179
|
+
const serverOpenCodeFetch = async (fetchPath, options = {}) => {
|
|
180
|
+
const method = String(options.method || 'GET').toUpperCase();
|
|
181
|
+
const base = buildOpenCodeUrl(fetchPath, '');
|
|
182
|
+
const params = new URLSearchParams(options.query || {});
|
|
183
|
+
if (options.directory) params.set('directory', options.directory);
|
|
184
|
+
const search = params.toString();
|
|
185
|
+
const url = search ? `${base}${base.includes('?') ? '&' : '?'}${search}` : base;
|
|
186
|
+
|
|
187
|
+
const pathOnly = String(fetchPath || '').split('?')[0] || '';
|
|
188
|
+
const blocked = assertServerOpenCodeExecutionAllowed({
|
|
189
|
+
getRuntimeContract,
|
|
190
|
+
method,
|
|
191
|
+
pathWithQuery: pathOnly,
|
|
192
|
+
allowMissingContract,
|
|
193
|
+
});
|
|
194
|
+
if (blocked) {
|
|
195
|
+
const error = new Error(blocked.error);
|
|
196
|
+
error.code = blocked.errorCode;
|
|
197
|
+
error.status = 409;
|
|
198
|
+
error.body = blocked;
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const signal = options.signal
|
|
203
|
+
|| (typeof options.timeoutMs === 'number' && options.timeoutMs > 0
|
|
204
|
+
? AbortSignal.timeout(options.timeoutMs)
|
|
205
|
+
: undefined);
|
|
206
|
+
|
|
207
|
+
const response = await gatedFetch(url, {
|
|
208
|
+
method,
|
|
209
|
+
headers: {
|
|
210
|
+
Accept: 'application/json',
|
|
211
|
+
...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
212
|
+
...getOpenCodeAuthHeaders(),
|
|
213
|
+
},
|
|
214
|
+
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
|
|
215
|
+
...(signal ? { signal } : {}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
let body = null;
|
|
220
|
+
try {
|
|
221
|
+
body = await response.json();
|
|
222
|
+
} catch {
|
|
223
|
+
body = null;
|
|
224
|
+
}
|
|
225
|
+
if (body && body.errorCode === 'RUNTIME_CONTRACT_EXECUTION_BLOCKED') {
|
|
226
|
+
const error = new Error(body.error || 'OpenCode runtime contract blocked execution');
|
|
227
|
+
error.code = body.errorCode;
|
|
228
|
+
error.status = response.status;
|
|
229
|
+
error.body = body;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
const error = new Error(`OpenCode ${method} ${fetchPath} failed with ${response.status}`);
|
|
233
|
+
error.status = response.status;
|
|
234
|
+
error.body = body;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return response.json().catch(() => null);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return serverOpenCodeFetch;
|
|
242
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
assertServerOpenCodeExecutionAllowed,
|
|
4
|
+
configureServerOpenCodeFetchGate,
|
|
5
|
+
createServerOpenCodeFetch,
|
|
6
|
+
resolveFetchMethodAndPath,
|
|
7
|
+
wrapFetchWithRuntimeContractGate,
|
|
8
|
+
} from './server-opencode-fetch.js';
|
|
9
|
+
import { makeOpenCodeV2Client } from './v2-client.js';
|
|
10
|
+
|
|
11
|
+
describe('serverOpenCodeFetch transport gate (ticket 11)', () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
configureServerOpenCodeFetchGate(null);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('resolves method and path from URL strings', () => {
|
|
17
|
+
expect(resolveFetchMethodAndPath('http://127.0.0.1:4096/session/ses_1/prompt', { method: 'POST' }))
|
|
18
|
+
.toEqual({ method: 'POST', pathWithQuery: '/session/ses_1/prompt' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('blocks write execution when contract denies, keeps reads and stop-task', () => {
|
|
22
|
+
const contract = {
|
|
23
|
+
executionAllowed: false,
|
|
24
|
+
phase: 'ready-unverified',
|
|
25
|
+
reasons: ['unverified-newer'],
|
|
26
|
+
serveVersion: '2.1.0',
|
|
27
|
+
minVerifiedVersion: '2.0.12',
|
|
28
|
+
maxVerifiedVersion: '2.0.14',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
expect(assertServerOpenCodeExecutionAllowed({
|
|
32
|
+
getRuntimeContract: () => contract,
|
|
33
|
+
method: 'POST',
|
|
34
|
+
pathWithQuery: '/session/ses_1/prompt_async',
|
|
35
|
+
})?.errorCode).toBe('RUNTIME_CONTRACT_EXECUTION_BLOCKED');
|
|
36
|
+
|
|
37
|
+
expect(assertServerOpenCodeExecutionAllowed({
|
|
38
|
+
getRuntimeContract: () => contract,
|
|
39
|
+
method: 'GET',
|
|
40
|
+
pathWithQuery: '/session/ses_1/message',
|
|
41
|
+
})).toBeNull();
|
|
42
|
+
|
|
43
|
+
expect(assertServerOpenCodeExecutionAllowed({
|
|
44
|
+
getRuntimeContract: () => contract,
|
|
45
|
+
method: 'POST',
|
|
46
|
+
pathWithQuery: '/session/ses_1/interrupt',
|
|
47
|
+
})).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('production default blocks Host writes when contract is missing', () => {
|
|
51
|
+
expect(assertServerOpenCodeExecutionAllowed({
|
|
52
|
+
getRuntimeContract: () => null,
|
|
53
|
+
method: 'POST',
|
|
54
|
+
pathWithQuery: '/session/ses_1/prompt',
|
|
55
|
+
})?.errorCode).toBe('RUNTIME_CONTRACT_EXECUTION_BLOCKED');
|
|
56
|
+
|
|
57
|
+
expect(assertServerOpenCodeExecutionAllowed({
|
|
58
|
+
getRuntimeContract: () => null,
|
|
59
|
+
method: 'POST',
|
|
60
|
+
pathWithQuery: '/session/ses_1/prompt',
|
|
61
|
+
allowMissingContract: true,
|
|
62
|
+
})).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('wrapFetch returns 409 JSON without calling upstream on blocked writes', async () => {
|
|
66
|
+
const upstream = vi.fn(async () => new Response('ok'));
|
|
67
|
+
configureServerOpenCodeFetchGate(() => ({
|
|
68
|
+
executionAllowed: false,
|
|
69
|
+
phase: 'incompatible',
|
|
70
|
+
reasons: ['below-min-verified'],
|
|
71
|
+
serveVersion: '2.0.5',
|
|
72
|
+
minVerifiedVersion: '2.0.12',
|
|
73
|
+
}));
|
|
74
|
+
const gated = wrapFetchWithRuntimeContractGate(upstream);
|
|
75
|
+
const response = await gated('http://opencode.test/session/ses_1/prompt', { method: 'POST', body: '{}' });
|
|
76
|
+
expect(response.status).toBe(409);
|
|
77
|
+
const body = await response.json();
|
|
78
|
+
expect(body.errorCode).toBe('RUNTIME_CONTRACT_EXECUTION_BLOCKED');
|
|
79
|
+
expect(upstream).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('createServerOpenCodeFetch throws structured error on blocked prompt', async () => {
|
|
83
|
+
const fetchImpl = vi.fn();
|
|
84
|
+
const serverOpenCodeFetch = createServerOpenCodeFetch({
|
|
85
|
+
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
|
86
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
87
|
+
getRuntimeContract: () => ({
|
|
88
|
+
executionAllowed: false,
|
|
89
|
+
phase: 'ready-unverified',
|
|
90
|
+
reasons: ['unverified-newer'],
|
|
91
|
+
serveVersion: '2.1.0',
|
|
92
|
+
}),
|
|
93
|
+
fetchImpl,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(serverOpenCodeFetch('/session/ses_1/prompt_async', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
body: { parts: [] },
|
|
99
|
+
})).rejects.toMatchObject({
|
|
100
|
+
code: 'RUNTIME_CONTRACT_EXECUTION_BLOCKED',
|
|
101
|
+
status: 409,
|
|
102
|
+
});
|
|
103
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('makeOpenCodeV2Client uses gated fetch so host SDK cannot bypass admission', async () => {
|
|
107
|
+
const upstream = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
|
|
108
|
+
status: 200,
|
|
109
|
+
headers: { 'content-type': 'application/json' },
|
|
110
|
+
}));
|
|
111
|
+
configureServerOpenCodeFetchGate(() => ({
|
|
112
|
+
executionAllowed: false,
|
|
113
|
+
phase: 'incompatible',
|
|
114
|
+
reasons: ['below-min-verified'],
|
|
115
|
+
serveVersion: '2.0.1',
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
// Client construction must not throw; gate applies on network.
|
|
119
|
+
const client = makeOpenCodeV2Client({
|
|
120
|
+
baseUrl: 'http://opencode.test',
|
|
121
|
+
fetchImpl: upstream,
|
|
122
|
+
});
|
|
123
|
+
expect(client).toBeTruthy();
|
|
124
|
+
|
|
125
|
+
const gated = wrapFetchWithRuntimeContractGate(upstream);
|
|
126
|
+
const blocked = await gated('http://opencode.test/session', { method: 'POST', body: '{}' });
|
|
127
|
+
expect(blocked.status).toBe(409);
|
|
128
|
+
expect(upstream).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { registerOpenCodeProxy } from './proxy.js';
|
|
2
2
|
import { pathLooksUserConfigured, mergePathValues } from './path-utils.js';
|
|
3
|
+
import {
|
|
4
|
+
configureServerOpenCodeFetchGate,
|
|
5
|
+
createServerOpenCodeFetch,
|
|
6
|
+
wrapFetchWithRuntimeContractGate,
|
|
7
|
+
} from './server-opencode-fetch.js';
|
|
3
8
|
|
|
4
9
|
export const createServerUtilsRuntime = (dependencies) => {
|
|
5
10
|
const {
|
|
@@ -22,8 +27,30 @@ export const createServerUtilsRuntime = (dependencies) => {
|
|
|
22
27
|
clearLastOpenCodeError,
|
|
23
28
|
getLoginShellPath,
|
|
24
29
|
getStoredSessionMetadata = null,
|
|
30
|
+
getStoredSessionMetadataSync = null,
|
|
31
|
+
ensureSessionMetadataReady = null,
|
|
32
|
+
onSessionDeleted = null,
|
|
25
33
|
} = dependencies;
|
|
26
34
|
|
|
35
|
+
const getRuntimeContract = () => {
|
|
36
|
+
try {
|
|
37
|
+
return getRuntime()?.runtimeContract ?? null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Shared Host transport gate for SDK clients + raw serverOpenCodeFetch.
|
|
44
|
+
configureServerOpenCodeFetchGate(getRuntimeContract);
|
|
45
|
+
|
|
46
|
+
const serverOpenCodeFetch = createServerOpenCodeFetch({
|
|
47
|
+
buildOpenCodeUrl,
|
|
48
|
+
getOpenCodeAuthHeaders,
|
|
49
|
+
getRuntimeContract,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const gatedOpenCodeFetchImpl = wrapFetchWithRuntimeContractGate(fetch, { getRuntimeContract });
|
|
53
|
+
|
|
27
54
|
const setOpenCodePort = (port) => {
|
|
28
55
|
if (!Number.isFinite(port) || port <= 0) {
|
|
29
56
|
return;
|
|
@@ -217,6 +244,9 @@ export const createServerUtilsRuntime = (dependencies) => {
|
|
|
217
244
|
onInteractiveSessionRequest: options.onInteractiveSessionRequest,
|
|
218
245
|
onSessionTurnAdmission: options.onSessionTurnAdmission,
|
|
219
246
|
getStoredSessionMetadata,
|
|
247
|
+
getStoredSessionMetadataSync,
|
|
248
|
+
ensureSessionMetadataReady,
|
|
249
|
+
onSessionDeleted,
|
|
220
250
|
});
|
|
221
251
|
};
|
|
222
252
|
|
|
@@ -230,5 +260,8 @@ export const createServerUtilsRuntime = (dependencies) => {
|
|
|
230
260
|
fetchProvidersSnapshot,
|
|
231
261
|
fetchModelsSnapshot,
|
|
232
262
|
setupProxy,
|
|
263
|
+
serverOpenCodeFetch,
|
|
264
|
+
gatedOpenCodeFetchImpl,
|
|
265
|
+
getRuntimeContract,
|
|
233
266
|
};
|
|
234
267
|
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createSharedOpenCodeService, isProcessAlive } from './shared-service.js';
|
|
9
|
+
|
|
10
|
+
// Opt-in real service, with isolated registration, config and database. No model calls.
|
|
11
|
+
test.skipIf(!process.env.OPENCHAMBER_TEST_OPENCODE_BINARY)('shared service survives its launcher, is reused, and can be replaced explicitly', async () => {
|
|
12
|
+
const temporary = path.join(os.tmpdir(), 'opencode');
|
|
13
|
+
await fs.mkdir(temporary, { recursive: true });
|
|
14
|
+
const root = await fs.mkdtemp(path.join(temporary, 'shared-service-'));
|
|
15
|
+
const config = path.join(root, 'config', 'opencode');
|
|
16
|
+
await fs.mkdir(config, { recursive: true });
|
|
17
|
+
const listener = net.createServer();
|
|
18
|
+
await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve));
|
|
19
|
+
const port = listener.address().port;
|
|
20
|
+
await new Promise((resolve) => listener.close(resolve));
|
|
21
|
+
await fs.writeFile(path.join(config, 'service.json'), JSON.stringify({ hostname: '127.0.0.1', port }));
|
|
22
|
+
const env = {
|
|
23
|
+
PATH: process.env.PATH,
|
|
24
|
+
HOME: root,
|
|
25
|
+
TMPDIR: os.tmpdir(),
|
|
26
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
27
|
+
XDG_DATA_HOME: path.join(root, 'data'),
|
|
28
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
29
|
+
XDG_CACHE_HOME: path.join(root, 'cache'),
|
|
30
|
+
OPENCODE_CONFIG_DIR: config,
|
|
31
|
+
OPENCODE_DISABLE_MODELS_FETCH: '1',
|
|
32
|
+
};
|
|
33
|
+
const binary = process.env.OPENCHAMBER_TEST_OPENCODE_BINARY;
|
|
34
|
+
const service = createSharedOpenCodeService({
|
|
35
|
+
envLike: env,
|
|
36
|
+
spawnImpl: (command, args, options) => spawn(command, args, { ...options, cwd: root }),
|
|
37
|
+
});
|
|
38
|
+
const probe = async (registration) => {
|
|
39
|
+
const response = await fetch(`${registration.origin}/api/info`, {
|
|
40
|
+
headers: { Authorization: `Basic ${Buffer.from(`opencode:${registration.password}`).toString('base64')}` },
|
|
41
|
+
signal: AbortSignal.timeout(5000),
|
|
42
|
+
});
|
|
43
|
+
expect(response.ok).toBe(true);
|
|
44
|
+
return response.json();
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
await service.start({ binary, env });
|
|
48
|
+
const first = await service.readRegistration();
|
|
49
|
+
expect(first?.pid).toBeGreaterThan(0);
|
|
50
|
+
expect(isProcessAlive(first.pid)).toBe(true);
|
|
51
|
+
expect((await probe(first)).version).toBe(first.version);
|
|
52
|
+
|
|
53
|
+
await service.start({ binary, env });
|
|
54
|
+
const reused = await service.readRegistration();
|
|
55
|
+
expect(reused.pid).toBe(first.pid);
|
|
56
|
+
|
|
57
|
+
await service.start({ binary, env, replace: true });
|
|
58
|
+
const replacement = await service.readRegistration();
|
|
59
|
+
expect(replacement.pid).not.toBe(first.pid);
|
|
60
|
+
expect(isProcessAlive(first.pid)).toBe(false);
|
|
61
|
+
expect((await probe(replacement)).version).toBe(replacement.version);
|
|
62
|
+
} finally {
|
|
63
|
+
await promisify(execFile)(binary, ['service', 'stop'], { env, cwd: root, timeout: 20_000, windowsHide: true });
|
|
64
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}, 120_000);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// Official OpenCode background service (`opencode serve --service`). Every
|
|
7
|
+
// official client on this machine — TUI, CLI, Desktop — finds it through one
|
|
8
|
+
// registration file and shares its process, database, and config watchers, so
|
|
9
|
+
// providers connected or plugins changed elsewhere are live here too.
|
|
10
|
+
|
|
11
|
+
const SERVICE_START_TIMEOUT_MS = 45_000;
|
|
12
|
+
|
|
13
|
+
/** Stable-channel registration file (`latest`/`beta`/`next`/`dev` share it). */
|
|
14
|
+
export const resolveSharedServiceRegistrationPath = (envLike = process.env, homeDir = os.homedir()) => {
|
|
15
|
+
const stateHome = typeof envLike.XDG_STATE_HOME === 'string' && envLike.XDG_STATE_HOME.trim()
|
|
16
|
+
? envLike.XDG_STATE_HOME.trim()
|
|
17
|
+
: path.join(homeDir, '.local', 'state');
|
|
18
|
+
return path.join(stateHome, 'opencode', 'service.json');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** `OPENCHAMBER_OPENCODE_SHARED_SERVICE=0` opts back into a private managed `opencode serve`. */
|
|
22
|
+
export const isSharedServiceEnabled = (envLike = process.env) => {
|
|
23
|
+
const raw = String(envLike.OPENCHAMBER_OPENCODE_SHARED_SERVICE ?? '').trim().toLowerCase();
|
|
24
|
+
return raw !== '0' && raw !== 'false' && raw !== 'off';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** @returns {{ origin: string, port: number, pid: number | null, password: string | null, version: string | null } | null} */
|
|
28
|
+
export const parseSharedServiceRegistration = (text) => {
|
|
29
|
+
let value;
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!value || typeof value !== 'object' || typeof value.url !== 'string') return null;
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(value.url);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
43
|
+
const port = Number.parseInt(url.port, 10);
|
|
44
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
45
|
+
return {
|
|
46
|
+
origin: url.origin,
|
|
47
|
+
port,
|
|
48
|
+
pid: Number.isInteger(value.pid) && value.pid > 0 ? value.pid : null,
|
|
49
|
+
password: typeof value.password === 'string' && value.password.trim() ? value.password.trim() : null,
|
|
50
|
+
version: typeof value.version === 'string' ? value.version : null,
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const readSharedServiceRegistration = async ({ file = resolveSharedServiceRegistrationPath(), fsLike = fs } = {}) => {
|
|
55
|
+
const text = await fsLike.readFile(file, 'utf8').catch(() => null);
|
|
56
|
+
return text == null ? null : parseSharedServiceRegistration(text);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Environment inherited by a service OpenChamber starts. The service outlives
|
|
61
|
+
* OpenChamber and passes its env to every tool, so OpenChamber credentials
|
|
62
|
+
* (`OPENCHAMBER_*`, the managed-serve password) never travel with it; the
|
|
63
|
+
* service owns its own persistent password.
|
|
64
|
+
*/
|
|
65
|
+
export const buildSharedServiceStartEnv = (baseEnv = {}) => {
|
|
66
|
+
const env = {};
|
|
67
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
68
|
+
if (typeof value !== 'string') continue;
|
|
69
|
+
if (key.startsWith('OPENCHAMBER_')) continue;
|
|
70
|
+
if (key === 'OPENCODE_SERVER_PASSWORD' || key === 'OPENCODE_PASSWORD') continue;
|
|
71
|
+
env[key] = value;
|
|
72
|
+
}
|
|
73
|
+
return env;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `opencode service start` reuses a healthy compatible service or spawns a
|
|
78
|
+
* detached one, then exits once it is ready. Normal recovery does not replace
|
|
79
|
+
* a healthy service; explicit upgrades use `service restart` with the target.
|
|
80
|
+
*/
|
|
81
|
+
export const startSharedOpenCodeService = ({
|
|
82
|
+
binary,
|
|
83
|
+
args = [],
|
|
84
|
+
env,
|
|
85
|
+
spawnImpl = spawn,
|
|
86
|
+
timeoutMs = SERVICE_START_TIMEOUT_MS,
|
|
87
|
+
replace = false,
|
|
88
|
+
}) => new Promise((resolve, reject) => {
|
|
89
|
+
const action = replace ? 'restart' : 'start';
|
|
90
|
+
const child = spawnImpl(binary, [...args, 'service', action], {
|
|
91
|
+
env,
|
|
92
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
93
|
+
windowsHide: true,
|
|
94
|
+
});
|
|
95
|
+
let stderr = '';
|
|
96
|
+
let settled = false;
|
|
97
|
+
const finish = (error) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
if (error) reject(error);
|
|
102
|
+
else resolve();
|
|
103
|
+
};
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
try {
|
|
106
|
+
child.kill('SIGTERM');
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
finish(new Error(`Timed out waiting for \`opencode service ${action}\``));
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
child.stderr?.on('data', (chunk) => {
|
|
112
|
+
stderr = (stderr + chunk.toString()).slice(-2000);
|
|
113
|
+
});
|
|
114
|
+
child.once('error', (error) => finish(error));
|
|
115
|
+
child.once('exit', (code) => {
|
|
116
|
+
if (code === 0) {
|
|
117
|
+
finish(null);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const detail = stderr.trim();
|
|
121
|
+
finish(new Error(`\`opencode service ${action}\` exited with code ${code}${detail ? `: ${detail}` : ''}`));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const isProcessAlive = (pid) => {
|
|
126
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, 0);
|
|
129
|
+
return true;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return error?.code === 'EPERM';
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const createSharedOpenCodeService = ({
|
|
136
|
+
envLike = process.env,
|
|
137
|
+
fsLike = fs,
|
|
138
|
+
spawnImpl = spawn,
|
|
139
|
+
} = {}) => {
|
|
140
|
+
const file = resolveSharedServiceRegistrationPath(envLike);
|
|
141
|
+
return {
|
|
142
|
+
registrationFile: file,
|
|
143
|
+
buildStartEnv: buildSharedServiceStartEnv,
|
|
144
|
+
start: ({ binary, args, env, replace }) => startSharedOpenCodeService({ binary, args, env, replace, spawnImpl }),
|
|
145
|
+
readRegistration: () => readSharedServiceRegistration({ file, fsLike }),
|
|
146
|
+
};
|
|
147
|
+
};
|