@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
|
@@ -16,6 +16,7 @@ const originalPath = process.env.PATH;
|
|
|
16
16
|
|
|
17
17
|
afterEach(() => {
|
|
18
18
|
spawnMock.mockReset();
|
|
19
|
+
vi.useRealTimers();
|
|
19
20
|
vi.unstubAllGlobals();
|
|
20
21
|
if (typeof originalOpencodeBinary === 'string') {
|
|
21
22
|
process.env.OPENCODE_BINARY = originalOpencodeBinary;
|
|
@@ -182,6 +183,103 @@ describe('OpenCode lifecycle', () => {
|
|
|
182
183
|
await server.close();
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
it('allows a polite SIGTERM close that finishes after 400ms without escalating early', async () => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
delete process.env.OPENCODE_BINARY;
|
|
189
|
+
const child = createMockChild();
|
|
190
|
+
child.kill = vi.fn((signal) => {
|
|
191
|
+
if (signal === 'SIGTERM') {
|
|
192
|
+
// Slow-but-healthy flush: exits after 800ms (>400ms), still under 2500ms grace.
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
child.signalCode = 'SIGTERM';
|
|
195
|
+
child.emit('close', null, 'SIGTERM');
|
|
196
|
+
}, 800);
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
});
|
|
200
|
+
spawnMock.mockImplementationOnce(() => {
|
|
201
|
+
queueMicrotask(() => {
|
|
202
|
+
child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
|
|
203
|
+
});
|
|
204
|
+
return child;
|
|
205
|
+
});
|
|
206
|
+
stubOpenCodeFetch();
|
|
207
|
+
|
|
208
|
+
const runtime = createRuntime();
|
|
209
|
+
const server = await runtime.startOpenCode();
|
|
210
|
+
const closePromise = server.close();
|
|
211
|
+
|
|
212
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
213
|
+
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
214
|
+
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
|
215
|
+
|
|
216
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
217
|
+
await closePromise;
|
|
218
|
+
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
|
219
|
+
vi.useRealTimers();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('escalates a hung managed child to SIGKILL only after the full 2500ms SIGTERM grace', async () => {
|
|
223
|
+
vi.useFakeTimers();
|
|
224
|
+
delete process.env.OPENCODE_BINARY;
|
|
225
|
+
const child = createMockChild();
|
|
226
|
+
child.kill = vi.fn((signal) => {
|
|
227
|
+
if (signal === 'SIGKILL') {
|
|
228
|
+
child.signalCode = 'SIGKILL';
|
|
229
|
+
queueMicrotask(() => child.emit('close', null, 'SIGKILL'));
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
});
|
|
233
|
+
spawnMock.mockImplementationOnce(() => {
|
|
234
|
+
queueMicrotask(() => {
|
|
235
|
+
child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
|
|
236
|
+
});
|
|
237
|
+
return child;
|
|
238
|
+
});
|
|
239
|
+
stubOpenCodeFetch();
|
|
240
|
+
|
|
241
|
+
const runtime = createRuntime();
|
|
242
|
+
const server = await runtime.startOpenCode();
|
|
243
|
+
const closePromise = server.close();
|
|
244
|
+
|
|
245
|
+
await vi.advanceTimersByTimeAsync(2499);
|
|
246
|
+
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
247
|
+
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
|
|
248
|
+
|
|
249
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
250
|
+
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
|
251
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
252
|
+
await closePromise;
|
|
253
|
+
vi.useRealTimers();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('killProcessOnPort only signals the owned pid and never mass-kills by port', async () => {
|
|
257
|
+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
258
|
+
delete process.env.OPENCODE_BINARY;
|
|
259
|
+
const child = createMockChild();
|
|
260
|
+
spawnMock.mockImplementationOnce(() => {
|
|
261
|
+
queueMicrotask(() => {
|
|
262
|
+
child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
|
|
263
|
+
});
|
|
264
|
+
return child;
|
|
265
|
+
});
|
|
266
|
+
stubOpenCodeFetch();
|
|
267
|
+
|
|
268
|
+
const runtime = createRuntime();
|
|
269
|
+
await runtime.startOpenCode();
|
|
270
|
+
killSpy.mockClear();
|
|
271
|
+
|
|
272
|
+
runtime.killProcessOnPort(45678);
|
|
273
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
274
|
+
|
|
275
|
+
runtime.killProcessOnPort(45678, 4242);
|
|
276
|
+
expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL');
|
|
277
|
+
expect(killSpy).toHaveBeenCalledWith(4242, 'SIGKILL');
|
|
278
|
+
expect(killSpy.mock.calls.every(([target]) => target === -4242 || target === 4242)).toBe(true);
|
|
279
|
+
|
|
280
|
+
killSpy.mockRestore();
|
|
281
|
+
});
|
|
282
|
+
|
|
185
283
|
it('parses a v2 server listening line without the opencode prefix', async () => {
|
|
186
284
|
delete process.env.OPENCODE_BINARY;
|
|
187
285
|
const child = createMockChild();
|
|
@@ -741,6 +839,54 @@ describe('OpenCode lifecycle', () => {
|
|
|
741
839
|
expect(stateRef.current.isOpenCodeReady).toBe(false);
|
|
742
840
|
}, 15_000);
|
|
743
841
|
|
|
842
|
+
it('revokes execution permit on restart and ignores stale async health for a new generation', async () => {
|
|
843
|
+
const stateRef = {};
|
|
844
|
+
const runtime = createRuntime({}, stateRef);
|
|
845
|
+
stateRef.current.openCodePort = 45678;
|
|
846
|
+
stateRef.current.runtimeContractGeneration = 1;
|
|
847
|
+
stateRef.current.runtimeContract = {
|
|
848
|
+
executionAllowed: true,
|
|
849
|
+
phase: 'ready',
|
|
850
|
+
serveVersion: '2.0.12',
|
|
851
|
+
instanceGeneration: 1,
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const genBefore = runtime.revokeRuntimeExecutionPermit('test-restart');
|
|
855
|
+
expect(genBefore).toBe(2);
|
|
856
|
+
expect(stateRef.current.runtimeContract.executionAllowed).toBe(false);
|
|
857
|
+
expect(stateRef.current.runtimeContract.phase).toBe('pending');
|
|
858
|
+
expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
|
|
859
|
+
|
|
860
|
+
// Stale probe from generation 1 must not re-open execution on generation 2.
|
|
861
|
+
runtime.refreshRuntimeContractFromProbe(
|
|
862
|
+
{
|
|
863
|
+
ok: true,
|
|
864
|
+
version: '2.0.12',
|
|
865
|
+
authenticated: true,
|
|
866
|
+
healthOk: true,
|
|
867
|
+
},
|
|
868
|
+
{ admitTranscript: true, phase: 'done', error: null },
|
|
869
|
+
1,
|
|
870
|
+
);
|
|
871
|
+
expect(stateRef.current.runtimeContract.executionAllowed).toBe(false);
|
|
872
|
+
expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
|
|
873
|
+
|
|
874
|
+
// Matching generation publishes verified permit.
|
|
875
|
+
runtime.refreshRuntimeContractFromProbe(
|
|
876
|
+
{
|
|
877
|
+
ok: true,
|
|
878
|
+
version: '2.0.12',
|
|
879
|
+
authenticated: true,
|
|
880
|
+
healthOk: true,
|
|
881
|
+
},
|
|
882
|
+
{ admitTranscript: true, phase: 'done', error: null },
|
|
883
|
+
2,
|
|
884
|
+
);
|
|
885
|
+
expect(stateRef.current.runtimeContract.executionAllowed).toBe(true);
|
|
886
|
+
expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
|
|
887
|
+
expect(stateRef.current.runtimeContract.serveVersion).toBe('2.0.12');
|
|
888
|
+
});
|
|
889
|
+
|
|
744
890
|
it('does not mark external skip-start ready before version and migration admit', async () => {
|
|
745
891
|
const stateRef = {};
|
|
746
892
|
const runtime = createRuntime({
|
|
@@ -764,3 +910,179 @@ describe('OpenCode lifecycle', () => {
|
|
|
764
910
|
expect(stateRef.current.openCodePort).toBe(3001);
|
|
765
911
|
}, 25_000);
|
|
766
912
|
});
|
|
913
|
+
|
|
914
|
+
describe('shared official OpenCode service', () => {
|
|
915
|
+
const UNCONFIGURED_ENV = {
|
|
916
|
+
ENV_CONFIGURED_OPENCODE_PORT: null,
|
|
917
|
+
ENV_CONFIGURED_OPENCODE_HOST: null,
|
|
918
|
+
ENV_EFFECTIVE_PORT: null,
|
|
919
|
+
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
|
|
920
|
+
ENV_SKIP_OPENCODE_START: false,
|
|
921
|
+
};
|
|
922
|
+
const REGISTRATION = {
|
|
923
|
+
origin: 'http://127.0.0.1:49374',
|
|
924
|
+
port: 49374,
|
|
925
|
+
pid: process.pid,
|
|
926
|
+
password: 'service-password',
|
|
927
|
+
version: '2.0.12',
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
const createSharedService = (overrides = {}) => ({
|
|
931
|
+
registrationFile: '/state/opencode/service.json',
|
|
932
|
+
buildStartEnv: (env) => {
|
|
933
|
+
const { OPENCODE_SERVER_PASSWORD: _password, ...rest } = env;
|
|
934
|
+
return rest;
|
|
935
|
+
},
|
|
936
|
+
start: vi.fn(async () => {}),
|
|
937
|
+
readRegistration: vi.fn(async () => REGISTRATION),
|
|
938
|
+
...overrides,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
const stubSharedFetch = () => {
|
|
942
|
+
const base = stubOpenCodeFetch();
|
|
943
|
+
const fetchMock = vi.fn(async (url, init) => (String(url).includes('/api/location/reload')
|
|
944
|
+
? new Response(null, { status: 204 })
|
|
945
|
+
: base(url, init)));
|
|
946
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
947
|
+
return fetchMock;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
it('attaches to the registered service instead of spawning a private serve', async () => {
|
|
951
|
+
process.env.OPENCODE_BINARY = '/mock/opencode';
|
|
952
|
+
const sharedService = createSharedService();
|
|
953
|
+
const adoptOpenCodeServerPassword = vi.fn();
|
|
954
|
+
const stateRef = {};
|
|
955
|
+
const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword }, stateRef);
|
|
956
|
+
const fetchMock = stubSharedFetch();
|
|
957
|
+
|
|
958
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
959
|
+
|
|
960
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
961
|
+
expect(sharedService.start).toHaveBeenCalledTimes(1);
|
|
962
|
+
const startInput = sharedService.start.mock.calls[0][0];
|
|
963
|
+
expect(startInput.binary).toBe('/mock/opencode');
|
|
964
|
+
expect(startInput.env.OPENCODE_SERVER_PASSWORD).toBeUndefined();
|
|
965
|
+
expect(adoptOpenCodeServerPassword).toHaveBeenCalledWith('service-password', 'shared-service');
|
|
966
|
+
expect(stateRef.current.isExternalOpenCode).toBe(true);
|
|
967
|
+
expect(stateRef.current.openCodeBaseUrl).toBe('http://127.0.0.1:49374');
|
|
968
|
+
expect(stateRef.current.openCodePort).toBe(49374);
|
|
969
|
+
expect(runtime.isSharedOpenCodeService()).toBe(true);
|
|
970
|
+
// The leftover private serve on 4096 is never probed once the service wins.
|
|
971
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url).includes(':4096/'))).toBe(false);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it('reports shared startup failure without probing or spawning a private serve, and can retry', async () => {
|
|
975
|
+
process.env.OPENCODE_BINARY = '/mock/opencode';
|
|
976
|
+
const sharedService = createSharedService({ start: vi.fn(async () => { throw new Error('boom'); }) });
|
|
977
|
+
const adoptOpenCodeServerPassword = vi.fn();
|
|
978
|
+
const stateRef = {};
|
|
979
|
+
const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword }, stateRef);
|
|
980
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
981
|
+
const fetchMock = stubSharedFetch();
|
|
982
|
+
|
|
983
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
984
|
+
|
|
985
|
+
expect(adoptOpenCodeServerPassword).toHaveBeenLastCalledWith(null, null);
|
|
986
|
+
expect(runtime.isSharedOpenCodeService()).toBe(false);
|
|
987
|
+
expect(stateRef.current.isExternalOpenCode).toBe(false);
|
|
988
|
+
expect(stateRef.current.openCodeBaseUrl).toBeNull();
|
|
989
|
+
expect(stateRef.current.openCodeProcess).toBeNull();
|
|
990
|
+
expect(stateRef.current.isOpenCodeReady).toBe(false);
|
|
991
|
+
expect(stateRef.current.lastOpenCodeError).toBe('boom');
|
|
992
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
993
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
994
|
+
|
|
995
|
+
sharedService.start.mockResolvedValue(undefined);
|
|
996
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
997
|
+
expect(runtime.isSharedOpenCodeService()).toBe(true);
|
|
998
|
+
expect(stateRef.current.isOpenCodeReady).toBe(true);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('reconnects using updated registration without replacing the service', async () => {
|
|
1002
|
+
const sharedService = createSharedService();
|
|
1003
|
+
const adoptOpenCodeServerPassword = vi.fn();
|
|
1004
|
+
const stateRef = {};
|
|
1005
|
+
const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword }, stateRef);
|
|
1006
|
+
stubSharedFetch();
|
|
1007
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
1008
|
+
sharedService.readRegistration.mockResolvedValue({ ...REGISTRATION, port: 49375, origin: 'http://127.0.0.1:49375', password: 'rotated' });
|
|
1009
|
+
|
|
1010
|
+
await runtime.restartOpenCode();
|
|
1011
|
+
|
|
1012
|
+
expect(sharedService.start.mock.lastCall[0].replace).toBe(false);
|
|
1013
|
+
expect(stateRef.current.openCodePort).toBe(49375);
|
|
1014
|
+
expect(adoptOpenCodeServerPassword).toHaveBeenLastCalledWith('rotated', 'shared-service');
|
|
1015
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('reattaches shared ownership across HMR and retires an old private child', async () => {
|
|
1019
|
+
const sharedService = createSharedService();
|
|
1020
|
+
const stateRef = {};
|
|
1021
|
+
const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService }, stateRef);
|
|
1022
|
+
const close = vi.fn();
|
|
1023
|
+
stateRef.current.openCodeProcess = { pid: 12345, close };
|
|
1024
|
+
stateRef.current.openCodePort = 4096;
|
|
1025
|
+
stubSharedFetch();
|
|
1026
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
1027
|
+
expect(close).toHaveBeenCalledOnce();
|
|
1028
|
+
expect(stateRef.current.openCodeProcess).toBeNull();
|
|
1029
|
+
expect(runtime.isSharedOpenCodeService()).toBe(true);
|
|
1030
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
1031
|
+
expect(runtime.isSharedOpenCodeService()).toBe(true);
|
|
1032
|
+
expect(stateRef.current.isExternalOpenCode).toBe(true);
|
|
1033
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it('uses the explicit upgrade binary without settings rediscovery and propagates replacement failures', async () => {
|
|
1037
|
+
const sharedService = createSharedService();
|
|
1038
|
+
const applyOpencodeBinaryFromSettings = vi.fn();
|
|
1039
|
+
const stateRef = {};
|
|
1040
|
+
const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, applyOpencodeBinaryFromSettings }, stateRef);
|
|
1041
|
+
stubSharedFetch();
|
|
1042
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
1043
|
+
applyOpencodeBinaryFromSettings.mockClear();
|
|
1044
|
+
|
|
1045
|
+
await runtime.restartOpenCode({ binaryPath: '/cache/opencode-cli/2.0.14/opencode' });
|
|
1046
|
+
|
|
1047
|
+
expect(sharedService.start.mock.lastCall[0]).toMatchObject({ binary: '/cache/opencode-cli/2.0.14/opencode', replace: true });
|
|
1048
|
+
expect(applyOpencodeBinaryFromSettings).not.toHaveBeenCalled();
|
|
1049
|
+
sharedService.start.mockRejectedValue(new Error('replacement failed'));
|
|
1050
|
+
await expect(runtime.restartOpenCode({ binaryPath: '/cache/opencode-cli/2.0.14/opencode' })).rejects.toThrow('replacement failed');
|
|
1051
|
+
expect(stateRef.current.isOpenCodeReady).toBe(false);
|
|
1052
|
+
expect(stateRef.current.runtimeContract.executionAllowed).toBe(false);
|
|
1053
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('reloads config changes in place instead of restarting the shared service', async () => {
|
|
1057
|
+
process.env.OPENCODE_BINARY = '/mock/opencode';
|
|
1058
|
+
const sharedService = createSharedService();
|
|
1059
|
+
const stateRef = {};
|
|
1060
|
+
const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword: vi.fn() }, stateRef);
|
|
1061
|
+
const fetchMock = stubSharedFetch();
|
|
1062
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
1063
|
+
fetchMock.mockClear();
|
|
1064
|
+
|
|
1065
|
+
const result = await runtime.refreshOpenCodeAfterConfigChange('plugin add');
|
|
1066
|
+
|
|
1067
|
+
expect(result).toEqual({ reloaded: true, external: true });
|
|
1068
|
+
const reloadCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('/api/location/reload'));
|
|
1069
|
+
expect(reloadCalls).toHaveLength(1);
|
|
1070
|
+
expect(reloadCalls[0][1]?.method).toBe('POST');
|
|
1071
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
1072
|
+
expect(sharedService.start).toHaveBeenCalledTimes(1);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it('keeps an explicitly configured OpenCode port instead of the shared service', async () => {
|
|
1076
|
+
const sharedService = createSharedService();
|
|
1077
|
+
const stateRef = {};
|
|
1078
|
+
const runtime = createRuntime({ sharedService, adoptOpenCodeServerPassword: vi.fn() }, stateRef);
|
|
1079
|
+
stubSharedFetch();
|
|
1080
|
+
startListeningChild();
|
|
1081
|
+
|
|
1082
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
1083
|
+
|
|
1084
|
+
expect(sharedService.start).not.toHaveBeenCalled();
|
|
1085
|
+
expect(runtime.isSharedOpenCodeService()).toBe(false);
|
|
1086
|
+
await stateRef.current.openCodeProcess?.close();
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
@@ -122,15 +122,28 @@ export const createManagedCapabilitiesRuntime = ({
|
|
|
122
122
|
bridgeOrigin = parsed.origin;
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
const publishHostShim = async () => {
|
|
126
|
+
const sourceDir = pathLike.join(pathLike.dirname(fileURLToPath(import.meta.url)), 'v2-plugin-host-shim');
|
|
127
|
+
const target = pathLike.join(dataDir, 'managed-opencode-capabilities', 'v2-plugin-host-shim');
|
|
128
|
+
await fsLike.mkdir(target, { recursive: true });
|
|
129
|
+
await Promise.all(['package.json', 'index.mjs'].map((name) => fsLike.copyFile(pathLike.join(sourceDir, name), pathLike.join(target, name))));
|
|
130
|
+
return target;
|
|
131
|
+
};
|
|
132
|
+
|
|
125
133
|
const prepareManagedChildEnv = async (baseEnv = {}) => {
|
|
126
134
|
if (!bridgeOrigin) throw new Error('Managed scheduled-task bridge origin is unavailable');
|
|
127
|
-
const { pluginPath, instructionsPath } = await
|
|
135
|
+
const [{ pluginPath, instructionsPath }, shimDirectory] = await Promise.all([
|
|
136
|
+
publishResources(),
|
|
137
|
+
publishHostShim(),
|
|
138
|
+
]);
|
|
128
139
|
const token = cryptoLike.randomBytes(32).toString('hex');
|
|
129
140
|
const pluginUrl = pathToFileURL(pluginPath).href;
|
|
130
141
|
identity = { version: materialVersion, origin: bridgeOrigin, token, childPid: null };
|
|
142
|
+
const merged = JSON.parse(mergeManagedOpenCodeConfig({ configContent: baseEnv.OPENCODE_CONFIG_CONTENT, pluginUrl, instructionsUrl: instructionsPath }));
|
|
143
|
+
merged.plugin = stableDedupe([...(Array.isArray(merged.plugin) ? merged.plugin : []), shimDirectory]);
|
|
131
144
|
return {
|
|
132
145
|
...baseEnv,
|
|
133
|
-
OPENCODE_CONFIG_CONTENT:
|
|
146
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify(merged),
|
|
134
147
|
OPENCHAMBER_SCHEDULED_TASK_BRIDGE_ORIGIN: bridgeOrigin,
|
|
135
148
|
OPENCHAMBER_SCHEDULED_TASK_BRIDGE_PATH: MANAGED_SCHEDULED_TASK_TOOL_PATH,
|
|
136
149
|
OPENCHAMBER_SCHEDULED_TASK_TOKEN_HEADER: MANAGED_SCHEDULED_TASK_TOKEN_HEADER,
|
|
@@ -24,6 +24,7 @@ describe('managed OpenCode capabilities runtime', () => {
|
|
|
24
24
|
runtime.recordManagedChildPid(44);
|
|
25
25
|
const identity = runtime.getCapabilityIdentity();
|
|
26
26
|
expect(childEnv.OPENCODE_CONFIG_CONTENT).toContain('scheduled-task.mjs');
|
|
27
|
+
expect(JSON.parse(childEnv.OPENCODE_CONFIG_CONTENT).plugin).toContain(path.join(dataDir, 'managed-opencode-capabilities', 'v2-plugin-host-shim'));
|
|
27
28
|
expect(JSON.parse(childEnv.OPENCODE_CONFIG_CONTENT).instructions[0]).toMatch(/^\//);
|
|
28
29
|
expect(childEnv.OPENCHAMBER_SCHEDULED_TASK_BRIDGE_PATH).toBe(MANAGED_SCHEDULED_TASK_TOOL_PATH);
|
|
29
30
|
expect(childEnv.OPENCHAMBER_SCHEDULED_TASK_TOKEN_HEADER).toBe(MANAGED_SCHEDULED_TASK_TOKEN_HEADER);
|
|
@@ -14,11 +14,17 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
14
14
|
getCachedZenModels,
|
|
15
15
|
sessionIndexService,
|
|
16
16
|
sessionIndexSyncRuntime,
|
|
17
|
+
/** Production: committed Host metadata for archive-aware snapshot/lookup. */
|
|
18
|
+
getCommittedHostMetadata = null,
|
|
17
19
|
transcriptCacheService,
|
|
18
20
|
express,
|
|
19
21
|
} = dependencies;
|
|
20
22
|
|
|
21
|
-
registerSessionIndexRoutes(app, {
|
|
23
|
+
registerSessionIndexRoutes(app, {
|
|
24
|
+
sessionIndexService,
|
|
25
|
+
sessionIndexSyncRuntime,
|
|
26
|
+
getCommittedHostMetadata,
|
|
27
|
+
});
|
|
22
28
|
registerTranscriptCacheRoutes(app, { transcriptCacheService });
|
|
23
29
|
registerConfigSyncRoutes(app, { express });
|
|
24
30
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pinned desktop/runtime opencode2. Upgrade and prepare must not fall back to 1.18.x.
|
|
2
2
|
export const PINNED_OPENCODE2_VERSION = '2.0.12';
|
|
3
3
|
|
|
4
|
-
// Global npm/bun package that installs the
|
|
4
|
+
// Global npm/bun package that installs the opencode v2 binary (not 1.x opencode-ai).
|
|
5
5
|
export const OPENCODE2_NPM_PACKAGE = '@opencode/cli';
|
|
6
6
|
|
|
7
7
|
export function isOpenCode1xVersion(value) {
|
|
@@ -19,8 +19,8 @@ export function isAcceptableOpenCode2HealthVersion(value) {
|
|
|
19
19
|
if (typeof value !== 'string') return false;
|
|
20
20
|
const normalized = value.trim().replace(/^v/i, '');
|
|
21
21
|
if (!normalized) return false;
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Official CLI is 2.x. 1.x and pre-2 names like `opencode2 v0.0.0-beta-*` are not it.
|
|
23
|
+
if (!/^2(?:\.|$)/.test(normalized)) return false;
|
|
24
24
|
return /^\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized);
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -85,7 +85,7 @@ export function openCodeBinaryName(platform = process.platform) {
|
|
|
85
85
|
|
|
86
86
|
/** @deprecated Packaging leftover. Runtime install uses `openCodeBinaryName`. */
|
|
87
87
|
export function openCode2BinaryName(platform = process.platform) {
|
|
88
|
-
return platform
|
|
88
|
+
return openCodeBinaryName(platform);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export function resolveOpenCode2NpmArchitecture(arch = process.arch) {
|
|
@@ -43,6 +43,7 @@ describe('opencode2 pin (ticket 12)', () => {
|
|
|
43
43
|
it('accepts authoritative v2 health/info versions and rejects 1.x / missing / noise', () => {
|
|
44
44
|
expect(isAcceptableOpenCode2HealthVersion(PINNED_OPENCODE2_VERSION)).toBe(true);
|
|
45
45
|
expect(isAcceptableOpenCode2HealthVersion('v2.0.12')).toBe(true);
|
|
46
|
+
expect(isAcceptableOpenCode2HealthVersion('0.0.0-beta-18155')).toBe(false);
|
|
46
47
|
expect(isAcceptableOpenCode2HealthVersion('1.15.0')).toBe(false);
|
|
47
48
|
expect(isAcceptableOpenCode2HealthVersion('v1.18.18')).toBe(false);
|
|
48
49
|
expect(isAcceptableOpenCode2HealthVersion('')).toBe(false);
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
2
3
|
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
|
|
7
|
+
import { readOpenCode2BinaryVersion } from './ensure-cli.js';
|
|
5
8
|
import { createOpenCodeEnvRuntime } from './env-runtime.js';
|
|
6
9
|
import { createOpenCodeLifecycleRuntime } from './lifecycle.js';
|
|
7
10
|
import { createOpenCodeNetworkRuntime } from './network-runtime.js';
|
|
11
|
+
import {
|
|
12
|
+
evaluateOpenCodeHealthBody,
|
|
13
|
+
isAcceptableOpenCode2HealthVersion,
|
|
14
|
+
isOpenCode1xVersion,
|
|
15
|
+
} from './opencode2-pin.js';
|
|
8
16
|
import { fetchV1MigrationGate } from './v1-migration-gate.js';
|
|
9
17
|
|
|
10
18
|
const E2E_PASSWORD = 'openchamber-opencode2-e2e';
|
|
@@ -12,6 +20,21 @@ const basicAuth = `Basic ${Buffer.from(`opencode:${E2E_PASSWORD}`, 'utf8').toStr
|
|
|
12
20
|
const OPENCODE2_HEALTH_VERSION = /^(?:v)?(?:0\.0\.0-(?:next|beta)(?:-\d+)?|2(?:\.\d+)*)/i;
|
|
13
21
|
const OPENCODE_1X_VERSION = /^v?1(?:\.|$)/;
|
|
14
22
|
|
|
23
|
+
/** Env keys that must not leak into the real user OpenCode install during e2e. */
|
|
24
|
+
const ISOLATION_ENV_KEYS = [
|
|
25
|
+
'HOME',
|
|
26
|
+
'USERPROFILE',
|
|
27
|
+
'XDG_DATA_HOME',
|
|
28
|
+
'XDG_CONFIG_HOME',
|
|
29
|
+
'XDG_CACHE_HOME',
|
|
30
|
+
'XDG_STATE_HOME',
|
|
31
|
+
'OPENCODE_CONFIG',
|
|
32
|
+
'OPENCODE_CONFIG_DIR',
|
|
33
|
+
'OPENCODE_CONFIG_CONTENT',
|
|
34
|
+
'OPENCHAMBER_DATA_DIR',
|
|
35
|
+
'OPENCHAMBER_MANAGED_PROCESS_REGISTRY',
|
|
36
|
+
];
|
|
37
|
+
|
|
15
38
|
const freePort = () => new Promise((resolve, reject) => {
|
|
16
39
|
const server = net.createServer();
|
|
17
40
|
server.once('error', reject);
|
|
@@ -46,10 +69,72 @@ const jsonPost = async (url, pathname, payload) => {
|
|
|
46
69
|
return { status: response.status, body };
|
|
47
70
|
};
|
|
48
71
|
|
|
72
|
+
const snapshotEnv = (keys) => {
|
|
73
|
+
/** @type {Record<string, string | undefined>} */
|
|
74
|
+
const snap = {};
|
|
75
|
+
for (const key of keys) snap[key] = process.env[key];
|
|
76
|
+
return snap;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const restoreEnv = (snap) => {
|
|
80
|
+
for (const [key, value] of Object.entries(snap)) {
|
|
81
|
+
if (typeof value === 'string') process.env[key] = value;
|
|
82
|
+
else delete process.env[key];
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Point HOME + XDG + OpenChamber data at a fresh temp tree so managed opencode2
|
|
88
|
+
* never reads/writes the developer's real ~/.config/opencode or
|
|
89
|
+
* ~/.local/share/opencode (migration/auth/session DB).
|
|
90
|
+
* Does not seed from the user install — empty isolation only.
|
|
91
|
+
*/
|
|
92
|
+
const installIsolatedOpenCodeHome = async () => {
|
|
93
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-opencode2-e2e-'));
|
|
94
|
+
const home = path.join(root, 'home');
|
|
95
|
+
const xdgData = path.join(root, 'xdg-data');
|
|
96
|
+
const xdgConfig = path.join(root, 'xdg-config');
|
|
97
|
+
const xdgCache = path.join(root, 'xdg-cache');
|
|
98
|
+
const xdgState = path.join(root, 'xdg-state');
|
|
99
|
+
const openchamberData = path.join(root, 'openchamber-data');
|
|
100
|
+
const opencodeConfig = path.join(xdgConfig, 'opencode');
|
|
101
|
+
await Promise.all([
|
|
102
|
+
fs.mkdir(home, { recursive: true }),
|
|
103
|
+
fs.mkdir(xdgData, { recursive: true }),
|
|
104
|
+
fs.mkdir(xdgConfig, { recursive: true }),
|
|
105
|
+
fs.mkdir(xdgCache, { recursive: true }),
|
|
106
|
+
fs.mkdir(xdgState, { recursive: true }),
|
|
107
|
+
fs.mkdir(openchamberData, { recursive: true }),
|
|
108
|
+
fs.mkdir(opencodeConfig, { recursive: true }),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
process.env.HOME = home;
|
|
112
|
+
if (process.platform === 'win32') process.env.USERPROFILE = home;
|
|
113
|
+
process.env.XDG_DATA_HOME = xdgData;
|
|
114
|
+
process.env.XDG_CONFIG_HOME = xdgConfig;
|
|
115
|
+
process.env.XDG_CACHE_HOME = xdgCache;
|
|
116
|
+
process.env.XDG_STATE_HOME = xdgState;
|
|
117
|
+
process.env.OPENCODE_CONFIG_DIR = opencodeConfig;
|
|
118
|
+
delete process.env.OPENCODE_CONFIG;
|
|
119
|
+
delete process.env.OPENCODE_CONFIG_CONTENT;
|
|
120
|
+
process.env.OPENCHAMBER_DATA_DIR = openchamberData;
|
|
121
|
+
process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY = path.join(openchamberData, 'managed-opencode');
|
|
122
|
+
|
|
123
|
+
return root;
|
|
124
|
+
};
|
|
125
|
+
|
|
49
126
|
describe('opencode2 sidecar e2e', () => {
|
|
50
127
|
let server = null;
|
|
128
|
+
let isolationRoot = null;
|
|
51
129
|
const previousBinary = process.env.OPENCODE_BINARY;
|
|
52
130
|
const previousPassword = process.env.OPENCODE_PASSWORD;
|
|
131
|
+
const previousIsolation = snapshotEnv(ISOLATION_ENV_KEYS);
|
|
132
|
+
// Captured at suite load (before installIsolatedOpenCodeHome mutates HOME).
|
|
133
|
+
const realUserHome = path.resolve(
|
|
134
|
+
previousIsolation.HOME
|
|
135
|
+
|| previousIsolation.USERPROFILE
|
|
136
|
+
|| os.homedir(),
|
|
137
|
+
);
|
|
53
138
|
|
|
54
139
|
afterEach(async () => {
|
|
55
140
|
if (server?.close) {
|
|
@@ -66,9 +151,16 @@ describe('opencode2 sidecar e2e', () => {
|
|
|
66
151
|
} else {
|
|
67
152
|
delete process.env.OPENCODE_PASSWORD;
|
|
68
153
|
}
|
|
154
|
+
restoreEnv(previousIsolation);
|
|
155
|
+
if (isolationRoot) {
|
|
156
|
+
await fs.rm(isolationRoot, { recursive: true, force: true }).catch(() => {});
|
|
157
|
+
isolationRoot = null;
|
|
158
|
+
}
|
|
69
159
|
});
|
|
70
160
|
|
|
71
161
|
it('starts managed opencode2 and serves health, migration, session, and message projection', async () => {
|
|
162
|
+
// Resolve the v2 binary against the real machine first (PATH + home fallbacks).
|
|
163
|
+
// Isolation mutates HOME/XDG and would hide ~/.bun/bin and similar fallbacks.
|
|
72
164
|
delete process.env.OPENCODE_BINARY;
|
|
73
165
|
delete process.env.OPENCODE_PATH;
|
|
74
166
|
delete process.env.OPENCHAMBER_OPENCODE_PATH;
|
|
@@ -93,7 +185,16 @@ describe('opencode2 sidecar e2e', () => {
|
|
|
93
185
|
|
|
94
186
|
const resolved = envRuntime.resolveOpencodeCliPath();
|
|
95
187
|
expect(resolved).toBeTruthy();
|
|
96
|
-
|
|
188
|
+
// Official v2 may install as `opencode` or legacy alias `opencode2`.
|
|
189
|
+
// Contract is version admission, not basename.
|
|
190
|
+
const binaryVersion = readOpenCode2BinaryVersion(resolved);
|
|
191
|
+
expect(isAcceptableOpenCode2HealthVersion(binaryVersion)).toBe(true);
|
|
192
|
+
expect(isOpenCode1xVersion(binaryVersion)).toBe(false);
|
|
193
|
+
|
|
194
|
+
// Isolate HOME/XDG/OpenChamber data BEFORE spawn so migration/auth never
|
|
195
|
+
// touch the developer's real OpenCode store. Absolute OPENCODE_BINARY keeps
|
|
196
|
+
// the resolved CLI after HOME changes.
|
|
197
|
+
isolationRoot = await installIsolatedOpenCodeHome();
|
|
97
198
|
|
|
98
199
|
process.env.OPENCODE_BINARY = resolved;
|
|
99
200
|
process.env.OPENCODE_PASSWORD = E2E_PASSWORD;
|
|
@@ -160,6 +261,15 @@ describe('opencode2 sidecar e2e', () => {
|
|
|
160
261
|
buildManagedOpenCodePath: () => process.env.PATH,
|
|
161
262
|
getManagedOpenCodeShellEnvSnapshot: () => ({
|
|
162
263
|
PATH: process.env.PATH,
|
|
264
|
+
HOME: process.env.HOME,
|
|
265
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
266
|
+
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
|
267
|
+
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
268
|
+
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
|
|
269
|
+
XDG_STATE_HOME: process.env.XDG_STATE_HOME,
|
|
270
|
+
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
|
271
|
+
OPENCHAMBER_DATA_DIR: process.env.OPENCHAMBER_DATA_DIR,
|
|
272
|
+
OPENCHAMBER_MANAGED_PROCESS_REGISTRY: process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY,
|
|
163
273
|
OPENCODE_PASSWORD: E2E_PASSWORD,
|
|
164
274
|
OPENCODE_SERVER_PASSWORD: E2E_PASSWORD,
|
|
165
275
|
}),
|
|
@@ -167,15 +277,20 @@ describe('opencode2 sidecar e2e', () => {
|
|
|
167
277
|
|
|
168
278
|
server = await runtime.startOpenCode();
|
|
169
279
|
expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
170
|
-
|
|
280
|
+
const launchedBinary = String(state.lastOpenCodeLaunchDiagnostics?.binary || '');
|
|
281
|
+
expect(launchedBinary.length).toBeGreaterThan(0);
|
|
282
|
+
// Launch path must still resolve to the same admitted v2 binary (name may be opencode).
|
|
283
|
+
expect(isAcceptableOpenCode2HealthVersion(readOpenCode2BinaryVersion(launchedBinary))).toBe(true);
|
|
171
284
|
|
|
172
285
|
const health = await jsonGet(server.url, '/api/info');
|
|
173
286
|
expect(health.status).toBe(200);
|
|
287
|
+
const healthGate = evaluateOpenCodeHealthBody(health.body);
|
|
288
|
+
expect(healthGate.ok).toBe(true);
|
|
289
|
+
expect(healthGate.version).toMatch(OPENCODE2_HEALTH_VERSION);
|
|
290
|
+
expect(String(health.body?.version ?? '')).not.toMatch(OPENCODE_1X_VERSION);
|
|
174
291
|
expect(health.body).toMatchObject({
|
|
175
|
-
healthy: true,
|
|
176
292
|
version: expect.stringMatching(OPENCODE2_HEALTH_VERSION),
|
|
177
293
|
});
|
|
178
|
-
expect(String(health.body?.version ?? '')).not.toMatch(OPENCODE_1X_VERSION);
|
|
179
294
|
|
|
180
295
|
const unauthenticated = await fetch(`${server.url}/api/info`);
|
|
181
296
|
expect(unauthenticated.status).toBe(401);
|
|
@@ -187,6 +302,14 @@ describe('opencode2 sidecar e2e', () => {
|
|
|
187
302
|
expect(gate.admitTranscript).toBe(true);
|
|
188
303
|
expect(['completed', 'absent']).toContain(gate.phase);
|
|
189
304
|
|
|
305
|
+
// Safety: isolation root must stay under tmp and never equal real user home.
|
|
306
|
+
expect(isolationRoot.startsWith(os.tmpdir())).toBe(true);
|
|
307
|
+
expect(path.resolve(process.env.HOME)).not.toBe(path.resolve(realUserHome));
|
|
308
|
+
// Real user OpenCode paths must remain untouched (no accidental migration write).
|
|
309
|
+
const realConfigMarker = path.join(realUserHome, '.config', 'opencode');
|
|
310
|
+
const isolatedConfig = process.env.OPENCODE_CONFIG_DIR;
|
|
311
|
+
expect(path.resolve(isolatedConfig)).not.toBe(path.resolve(realConfigMarker));
|
|
312
|
+
|
|
190
313
|
const created = await jsonPost(server.url, '/api/session', {});
|
|
191
314
|
expect(created.status).toBe(200);
|
|
192
315
|
const sessionID = created.body?.data?.id ?? created.body?.id;
|