@openchambery/web 2.0.0-beta.25 → 2.0.0-beta.29
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 +14 -0
- package/dist/assets/{AgentAvatar-FyTdEOMr.js → AgentAvatar-O2x_1cFU.js} +1 -1
- package/dist/assets/{AssistantView-CUqIFFyH.js → AssistantView-Bphg-QIn.js} +3 -3
- package/dist/assets/{DiagramView-DhEXKsIH.js → DiagramView-CRD_GFch.js} +1 -1
- package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
- package/dist/assets/{Icon-D7NdMmbi.js → Icon-BN87CyS4.js} +1 -1
- package/dist/assets/{MarkdownRenderer-CyacU8IE.js → MarkdownRenderer-D5KEGdBn.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-DgO-73sa.js → MarkdownRendererImpl-C143YQsV.js} +2 -2
- package/dist/assets/{MarkstreamRendererImpl-CZPmsOrD.js → MarkstreamRendererImpl-C4rgOuOP.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-BGKiZT4X.js → MobileDetailNavigation-D5tcF2Kg.js} +1 -1
- package/dist/assets/{MobileShareBridge-BFkZ-if6.js → MobileShareBridge-Bm0JZKHc.js} +3 -3
- package/dist/assets/MobileSurface-BzzBCVkG.js +2 -0
- package/dist/assets/{MultiRunWindow-BZLr1op3.js → MultiRunWindow-B6Sb3aEG.js} +1 -1
- package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-BLjZIBOs.js} +1 -1
- package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BOqRklf0.js} +1 -1
- package/dist/assets/SettingsView-Ds6x2z0F.js +25 -0
- package/dist/assets/{SettingsWindow-BMISHpTN.js → SettingsWindow-D6CN5ahJ.js} +1 -1
- package/dist/assets/{TerminalView-DKCcoHzi.js → TerminalView-DkzaJJfh.js} +1 -1
- package/dist/assets/{ToolOutputDialog-ByptQgFf.js → ToolOutputDialog-BegFpg1h.js} +1 -1
- package/dist/assets/{appThemeRegistry-b59_2rLn.js → appThemeRegistry-BMzuqD8_.js} +1 -1
- package/dist/assets/{button-e8fYWIMM.js → button-Ck8ipzBm.js} +1 -1
- package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-nU99prkD.js} +1 -1
- package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DMzZN1oi.js} +1 -1
- package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-DuftEj8B.js} +1 -1
- package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-g378BdZf.js} +1 -1
- package/dist/assets/{diagram-BBL-LfsC.js → diagram-CvO4P8ds.js} +1 -1
- package/dist/assets/{dialog-MRsJpHzu.js → dialog-ByGUkS3c.js} +1 -1
- package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
- package/dist/assets/fileMentionTouchSelection-Cuk9C_jN.js +2 -0
- package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-DuF89nFO.js} +1 -1
- package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
- package/dist/assets/{gitApi-CYFnq-nn.js → gitApi-BlWYyRdx.js} +3 -3
- package/dist/assets/{input-DbOe1mdT.js → input-CXhFmHcq.js} +1 -1
- package/dist/assets/insertionBoundaries-lAwXo-R-.js +1 -0
- package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
- package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
- package/dist/assets/{main-DOpUCN5f.js → main-BmiKRvkB.js} +2 -2
- package/dist/assets/main-D5SdJE5d.js +169 -0
- package/dist/assets/main-qSeeJRW1.css +1 -0
- package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-g_UsWzbA.js} +1 -1
- package/dist/assets/{mcpQueries-Bxq_6P-n.js → mcpQueries-0Oe-8vHC.js} +1 -1
- package/dist/assets/{miniChat-CBpEIoJn.js → miniChat-CSwFHa9K.js} +2 -2
- package/dist/assets/mobile-DzJO7usb.js +2 -0
- package/dist/assets/multirun-Bdd0yE3M.js +3 -0
- package/dist/assets/persistence-Dnj1QOdX.js +3 -0
- package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
- package/dist/assets/platform-pfYy32vr.js +6 -0
- package/dist/assets/projectMeta-2dbON46t.js +1 -0
- package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
- package/dist/assets/renderElectronMiniChatApp-Bjf5yS-2.js +2 -0
- package/dist/assets/renderMobileApp-Bv0VxQo1.js +28 -0
- package/dist/assets/{runtimeConfig-CgFmzQWA.js → runtimeConfig-BOk04YEP.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
- package/dist/assets/runtimeEndpointReset-Oo4f7T5P.js +148 -0
- package/dist/assets/{sessionChangeSummary-Bg9UCEjA.js → sessionChangeSummary-Bf5wJLSv.js} +2 -2
- package/dist/assets/{sessionLookup-4MZgvAtH.js → sessionLookup-pga3-Etf.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-CHqMkTI3.js} +1 -1
- package/dist/assets/{store-D8fmTWK3.js → store-Cuktref-.js} +3 -3
- package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-BgGraKxs.js} +1 -1
- package/dist/assets/{textarea-B5GMntHk.js → textarea-B0Od84ZM.js} +1 -1
- package/dist/assets/{tooltip-C64SdRvR.js → tooltip-BKL7J5hx.js} +1 -1
- package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
- package/dist/assets/{url-DF1NFwt_.js → url-DNRMq_0R.js} +1 -1
- package/dist/assets/{useAppFontEffects-Ykf_fh2W.js → useAppFontEffects-gMl7z7An.js} +53 -53
- package/dist/assets/{useAssistantUIStore-B75cQm_z.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-GOgauAay.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CpsCUPDZ.js → useEffectiveDirectory-DgAB7HWq.js} +1 -1
- package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-Djxu_uRy.js} +1 -1
- package/dist/assets/useMobileNavigationStore-D7uAoBXe.js +415 -0
- package/dist/assets/useSessionAutoCleanup-Cg7KJFGa.js +2 -0
- package/dist/assets/{useUIStore-Brsa2mKs.js → useUIStore-Dfx6TKvi.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-N119bHfO.js → useWorkerHighlightedLines-DKa2EWBH.js} +1 -1
- package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
- package/dist/assets/{zh-CN-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
- package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
- package/dist/index.html +10 -10
- package/dist/mini-chat.html +10 -10
- package/dist/mobile.html +10 -10
- package/package.json +3 -3
- package/server/index.js +8 -20
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +7 -4
- package/server/lib/git/routes.test.js +34 -0
- package/server/lib/git/service.js +3 -1
- package/server/lib/llm/DOCUMENTATION.md +6 -4
- package/server/lib/llm/generate.js +8 -9
- package/server/lib/llm/generate.test.js +33 -6
- package/server/lib/opencode/DOCUMENTATION.md +24 -20
- package/server/lib/opencode/agents.js +13 -22
- package/server/lib/opencode/auth-state-runtime.js +3 -3
- package/server/lib/opencode/auth-state-runtime.test.js +59 -0
- package/server/lib/opencode/auth.d.ts +13 -0
- package/server/lib/opencode/auth.js +74 -64
- package/server/lib/opencode/auth.test.js +57 -0
- package/server/lib/opencode/commands.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +72 -0
- package/server/lib/opencode/config-entity-routes.js +9 -122
- package/server/lib/opencode/config-entity-routes.test.js +7 -11
- package/server/lib/opencode/config-files.d.ts +17 -0
- package/server/lib/opencode/config-files.js +187 -0
- package/server/lib/opencode/config-files.test.js +201 -0
- package/server/lib/opencode/core-routes.js +0 -1
- package/server/lib/opencode/core-routes.test.js +0 -15
- package/server/lib/opencode/credential-boundary.d.ts +1 -0
- package/server/lib/opencode/credential-boundary.js +11 -0
- package/server/lib/opencode/ensure-cli.js +4 -1
- package/server/lib/opencode/env-config.js +4 -1
- package/server/lib/opencode/env-config.test.js +28 -0
- package/server/lib/opencode/env-runtime.test.js +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +9 -7
- package/server/lib/opencode/hmr-state-runtime.js +4 -4
- package/server/lib/opencode/lifecycle.js +1 -49
- package/server/lib/opencode/lifecycle.test.js +30 -4
- package/server/lib/opencode/location-release.d.ts +8 -0
- package/server/lib/opencode/location-release.js +32 -0
- package/server/lib/opencode/location-release.test.js +43 -0
- package/server/lib/opencode/mcp.js +1 -1
- package/server/lib/opencode/opencode2-pin.js +2 -1
- package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
- package/server/lib/opencode/plugin-routes.test.js +4 -2
- package/server/lib/opencode/plugins.js +12 -60
- package/server/lib/opencode/plugins.test.js +5 -3
- package/server/lib/opencode/providers.js +6 -3
- package/server/lib/opencode/proxy.js +7 -0
- package/server/lib/opencode/proxy.test.js +13 -0
- package/server/lib/opencode/routes.js +5 -5
- package/server/lib/opencode/routes.test.js +18 -18
- package/server/lib/opencode/runtime-contract.js +3 -2
- package/server/lib/opencode/runtime-contract.test.js +12 -12
- package/server/lib/opencode/shared.js +6 -175
- package/server/lib/opencode/upgrade-screen.js +4 -11
- package/server/lib/opencode/upgrade-screen.test.js +25 -25
- package/server/lib/opencode/version-alignment.test.js +40 -0
- package/server/lib/quota/DOCUMENTATION.md +9 -0
- package/server/lib/quota/provider-credentials.test.js +29 -0
- package/server/lib/quota/providers/claude.js +4 -4
- package/server/lib/quota/providers/codex.js +4 -4
- package/server/lib/quota/providers/codex.test.js +1 -1
- package/server/lib/quota/providers/copilot.js +5 -5
- package/server/lib/quota/providers/google/auth.js +3 -3
- package/server/lib/quota/providers/google/index.js +2 -2
- package/server/lib/quota/providers/index.js +3 -7
- package/server/lib/quota/providers/index.test.js +2 -2
- package/server/lib/quota/providers/kimi.js +4 -4
- package/server/lib/quota/providers/minimax-shared.js +4 -4
- package/server/lib/quota/providers/nanogpt.js +4 -4
- package/server/lib/quota/providers/openai.js +4 -4
- package/server/lib/quota/providers/openrouter.js +4 -4
- package/server/lib/quota/providers/wafer.js +4 -4
- package/server/lib/quota/providers/zai.js +4 -4
- package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
- package/server/lib/quota/routes.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +1 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -3
- package/server/lib/session-index/DOCUMENTATION.md +7 -0
- package/server/lib/session-index/event-ingest.js +13 -0
- package/server/lib/session-index/event-ingest.test.js +7 -0
- package/server/lib/session-index/service.js +45 -0
- package/server/lib/session-index/service.test.js +47 -0
- package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
- package/server/lib/session-metadata/session-projection.d.ts +1 -0
- package/server/lib/session-metadata/session-projection.js +23 -0
- package/server/lib/session-metadata/session-projection.test.js +35 -0
- package/server/lib/session-title/DOCUMENTATION.md +36 -8
- package/server/lib/session-title/native-runtime.test.js +42 -0
- package/server/lib/session-title/runtime.js +18 -8
- package/server/lib/session-title/session-access.js +108 -3
- package/server/lib/session-title/session-access.test.js +125 -7
- package/server/lib/tts/DOCUMENTATION.md +7 -5
- package/server/lib/tts/credential-source.test.js +23 -0
- package/server/lib/tts/routes.js +2 -2
- package/server/lib/tts/service.js +16 -37
- package/dist/assets/MobileSurface-CiQ9yAML.js +0 -2
- package/dist/assets/SettingsView-Bo1Xj4mZ.js +0 -25
- package/dist/assets/fileMentionTouchSelection-CMG5yrcJ.js +0 -3
- package/dist/assets/insertionBoundaries-C5b2f_mO.js +0 -1
- package/dist/assets/main-Bz3MB9dk.js +0 -169
- package/dist/assets/main-D2BzpnUt.css +0 -1
- package/dist/assets/mobile-CiFeGY2g.js +0 -2
- package/dist/assets/multirun-DnD1iWqj.js +0 -4
- package/dist/assets/persistence-CMWO3jFo.js +0 -3
- package/dist/assets/platform-B0p43squ.js +0 -6
- package/dist/assets/projectMeta-B1ShmM7W.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-DePrSFtn.js +0 -2
- package/dist/assets/renderMobileApp-CiGHeia2.js +0 -28
- package/dist/assets/runtimeEndpointReset-Dw46SzSQ.js +0 -149
- package/dist/assets/useDesktopWindowControlsLayout-kH_CrZfG.js +0 -1
- package/dist/assets/useMobileNavigationStore-CBo6pohX.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BuFi27vR.js +0 -2
- package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
- package/server/lib/opencode/snippets.js +0 -242
- package/server/lib/opencode/snippets.test.js +0 -97
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readConfigFile, readConfigLayers, getConfigForPath, getJsonEntrySource, getJsonWriteTarget, writeConfig, writeConfigText } from './config-files.js';
|
|
6
|
+
|
|
7
|
+
let root;
|
|
8
|
+
let globalDir;
|
|
9
|
+
const put = (file, text) => {
|
|
10
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
|
+
fs.writeFileSync(file, text);
|
|
12
|
+
return file;
|
|
13
|
+
};
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-config-files-'));
|
|
16
|
+
globalDir = path.join(root, 'xdg', 'opencode');
|
|
17
|
+
vi.stubEnv('XDG_CONFIG_HOME', path.dirname(globalDir));
|
|
18
|
+
vi.stubEnv('OPENCODE_CONFIG', '');
|
|
19
|
+
vi.stubEnv('OPENCODE_CONFIG_DIR', '');
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
vi.unstubAllEnvs();
|
|
24
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('physical configuration layers', () => {
|
|
28
|
+
it('resolves an explicit global directory at call time', () => {
|
|
29
|
+
const customDirectory = path.join(root, 'custom-directory');
|
|
30
|
+
put(path.join(customDirectory, 'opencode.jsonc'), '{"model":"custom-directory"}');
|
|
31
|
+
vi.stubEnv('OPENCODE_CONFIG_DIR', customDirectory);
|
|
32
|
+
expect(readConfigLayers().mergedConfig).toEqual({ model: 'custom-directory' });
|
|
33
|
+
expect(getJsonWriteTarget(readConfigLayers(), 'user').path).toBe(path.join(customDirectory, 'opencode.jsonc'));
|
|
34
|
+
});
|
|
35
|
+
it('merges both global documents and keeps each write target unmerged', () => {
|
|
36
|
+
const json = put(path.join(globalDir, 'opencode.json'), '{"model":"base","agent":{"base":{"model":"a"},"shared":{"model":"base","description":"kept"}}}');
|
|
37
|
+
const jsonc = put(path.join(globalDir, 'opencode.jsonc'), '{ // override\n "model":"override","agent":{"shared":{"model":"override"}},}');
|
|
38
|
+
put(path.join(globalDir, 'config.json'), '{"model":"legacy-not-loaded-by-v2"}');
|
|
39
|
+
const layers = readConfigLayers();
|
|
40
|
+
expect(layers.mergedConfig).toEqual({ model: 'override', agent: { base: { model: 'a' }, shared: { model: 'override', description: 'kept' } } });
|
|
41
|
+
expect(getJsonEntrySource(layers, 'agent', 'base').path).toBe(json);
|
|
42
|
+
expect(getJsonEntrySource(layers, 'agent', 'shared').path).toBe(jsonc);
|
|
43
|
+
expect(getConfigForPath(layers, jsonc)).toEqual({ model: 'override', agent: { shared: { model: 'override' } } });
|
|
44
|
+
expect(getJsonWriteTarget(layers, 'user').path).toBe(jsonc);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('loads explicit config above global and below discovered project documents', () => {
|
|
48
|
+
put(path.join(globalDir, 'opencode.json'), '{"model":"global","global":true}');
|
|
49
|
+
const custom = put(path.join(root, 'custom.jsonc'), '{"model":"explicit","explicit":true}');
|
|
50
|
+
vi.stubEnv('OPENCODE_CONFIG', custom);
|
|
51
|
+
const workspace = path.join(root, 'workspace');
|
|
52
|
+
const nested = path.join(workspace, 'nested');
|
|
53
|
+
put(path.join(workspace, 'opencode.json'), '{"model":"parent","parent":true}');
|
|
54
|
+
put(path.join(nested, 'opencode.jsonc'), '{"model":"child"}');
|
|
55
|
+
put(path.join(workspace, '.opencode', 'opencode.json'), '{"model":"supplementary"}');
|
|
56
|
+
const layers = readConfigLayers(nested);
|
|
57
|
+
expect(layers.mergedConfig).toEqual({ model: 'supplementary', global: true, explicit: true, parent: true });
|
|
58
|
+
expect(getJsonWriteTarget(layers, 'user').path).toBe(custom);
|
|
59
|
+
expect(getJsonWriteTarget(layers, 'project').path).toBe(path.join(workspace, '.opencode', 'opencode.json'));
|
|
60
|
+
vi.stubEnv('OPENCODE_CONFIG', '');
|
|
61
|
+
expect(readConfigLayers().mergedConfig).toEqual({ model: 'global', global: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each(['opencode.json', 'opencode.jsonc'])('reads a single %s and treats missing, blank and comments-only documents explicitly as empty', (name) => {
|
|
65
|
+
expect(readConfigLayers().mergedConfig).toEqual({});
|
|
66
|
+
const file = put(path.join(globalDir, name), ' \n');
|
|
67
|
+
expect(readConfigLayers().mergedConfig).toEqual({});
|
|
68
|
+
put(file, '// empty config\n/* documented */');
|
|
69
|
+
expect(readConfigLayers().mergedConfig).toEqual({});
|
|
70
|
+
put(file, '{"model":"one"}');
|
|
71
|
+
expect(readConfigLayers().mergedConfig).toEqual({ model: 'one' });
|
|
72
|
+
expect(getJsonWriteTarget(readConfigLayers(), 'user').path).toBe(file);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each(['{"model":', '{"model":"x", broken}', '[]', 'null', 'plain text'])('rejects malformed/non-object content %s without changing it', (text) => {
|
|
76
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), text);
|
|
77
|
+
expect(() => readConfigLayers()).toThrow('Invalid JSONC');
|
|
78
|
+
expect(() => writeConfig({ model: 'new' }, file)).toThrow('Invalid JSONC');
|
|
79
|
+
expect(() => writeConfigText('{}', file)).toThrow('Invalid JSONC');
|
|
80
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(text);
|
|
81
|
+
expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('propagates read failures instead of returning empty success', () => {
|
|
85
|
+
const original = fs.readFileSync;
|
|
86
|
+
vi.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => {
|
|
87
|
+
if (String(file) === path.join(globalDir, 'opencode.json')) throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
|
88
|
+
return original(file, ...args);
|
|
89
|
+
});
|
|
90
|
+
expect(() => readConfigLayers()).toThrow('denied');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('structural JSONC writes', () => {
|
|
95
|
+
it.each(['absolute', 'relative'])('preserves a %s dotfiles symlink while updating its target, permissions and backup', (kind) => {
|
|
96
|
+
const original = '{ // dotfiles comment\n "model":"old"\n}';
|
|
97
|
+
const target = put(path.join(root, 'dotfiles', 'config.jsonc'), original);
|
|
98
|
+
fs.chmodSync(target, 0o664);
|
|
99
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
100
|
+
const file = path.join(globalDir, 'opencode.jsonc');
|
|
101
|
+
const link = kind === 'relative' ? path.relative(globalDir, target) : target;
|
|
102
|
+
fs.symlinkSync(link, file);
|
|
103
|
+
|
|
104
|
+
writeConfig({ model: 'new' }, file);
|
|
105
|
+
expect(fs.lstatSync(file).isSymbolicLink()).toBe(true);
|
|
106
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
107
|
+
expect(readConfigFile(target)).toEqual({ model: 'new' });
|
|
108
|
+
expect(fs.readFileSync(target, 'utf8')).toContain('// dotfiles comment');
|
|
109
|
+
expect(fs.statSync(target).mode & 0o777).toBe(0o664);
|
|
110
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(original);
|
|
111
|
+
expect(fs.existsSync(`${target}.openchamber.backup`)).toBe(false);
|
|
112
|
+
|
|
113
|
+
const structured = fs.readFileSync(target, 'utf8');
|
|
114
|
+
writeConfigText('{"model":"raw"}', file);
|
|
115
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
116
|
+
expect(readConfigFile(target)).toEqual({ model: 'raw' });
|
|
117
|
+
expect(fs.statSync(target).mode & 0o777).toBe(0o664);
|
|
118
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(structured);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each(['writeFileSync', 'chmodSync', 'copyFileSync', 'renameSync'])('preserves a symlink and its original target when %s fails', (method) => {
|
|
122
|
+
const original = '{ // retained\n "model":"old"\n}';
|
|
123
|
+
const target = put(path.join(root, 'dotfiles', 'config.jsonc'), original);
|
|
124
|
+
fs.chmodSync(target, 0o640);
|
|
125
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
126
|
+
const file = path.join(globalDir, 'opencode.jsonc');
|
|
127
|
+
const link = path.relative(globalDir, target);
|
|
128
|
+
fs.symlinkSync(link, file);
|
|
129
|
+
vi.spyOn(fs, method).mockImplementation(() => { throw new Error('simulated IO failure'); });
|
|
130
|
+
expect(() => writeConfig({ model: 'new' }, file)).toThrow('simulated IO failure');
|
|
131
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
132
|
+
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
133
|
+
expect(fs.statSync(target).mode & 0o777).toBe(0o640);
|
|
134
|
+
for (const dir of [globalDir, path.dirname(target)]) {
|
|
135
|
+
expect(fs.readdirSync(dir).some((name) => name.endsWith('.tmp'))).toBe(false);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each(['structured', 'raw'])('rejects a dangling symlink during a %s write without replacing it', (kind) => {
|
|
140
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
141
|
+
const file = path.join(globalDir, 'opencode.jsonc');
|
|
142
|
+
const link = '../../dotfiles/missing.jsonc';
|
|
143
|
+
fs.symlinkSync(link, file);
|
|
144
|
+
const write = () => kind === 'structured' ? writeConfig({ model: 'new' }, file) : writeConfigText('{}', file);
|
|
145
|
+
expect(write).toThrow(expect.objectContaining({ code: 'ENOENT' }));
|
|
146
|
+
expect(fs.readlinkSync(file)).toBe(link);
|
|
147
|
+
expect(fs.existsSync(path.resolve(globalDir, link))).toBe(false);
|
|
148
|
+
expect(fs.readdirSync(globalDir)).toEqual(['opencode.jsonc']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('edits nested values, adds fields and removes fields without losing surrounding comments or unrelated bytes', () => {
|
|
152
|
+
const original = '\uFEFF{\r\n\t// model comment\r\n\t"model": "old",\r\n\t"agent": {"review": {/* owned */ "model":"old", "remove":true, /* trailing */}},\r\n\t// unrelated field\r\n\t"unrelated" : [ 1, 2 ], // leave alone\r\n}\r\n';
|
|
153
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), original);
|
|
154
|
+
const config = readConfigFile(file);
|
|
155
|
+
config.model = 'new';
|
|
156
|
+
config.agent.review.model = 'new';
|
|
157
|
+
delete config.agent.review.remove;
|
|
158
|
+
config.agent.review.description = 'added';
|
|
159
|
+
writeConfig(config, file);
|
|
160
|
+
const saved = fs.readFileSync(file, 'utf8');
|
|
161
|
+
expect(readConfigFile(file)).toEqual(config);
|
|
162
|
+
for (const comment of ['// model comment', '/* owned */', '/* trailing */', '// unrelated field', '// leave alone']) expect(saved).toContain(comment);
|
|
163
|
+
expect(saved).toContain('\t"unrelated" : [ 1, 2 ], // leave alone\r\n');
|
|
164
|
+
expect(saved.startsWith('\uFEFF')).toBe(true);
|
|
165
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(original);
|
|
166
|
+
writeConfig(config, file);
|
|
167
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(saved);
|
|
168
|
+
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(original);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it.each([
|
|
172
|
+
'{/* before */ "only":1, /* after , */}',
|
|
173
|
+
'{"keep":2, /* before */ "only":1 /* after */}',
|
|
174
|
+
'{/* before */ "only":1, /* after */ "keep":2}',
|
|
175
|
+
])('removes a property and its comma from %s', (text) => {
|
|
176
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), text);
|
|
177
|
+
const desired = readConfigFile(file);
|
|
178
|
+
delete desired.only;
|
|
179
|
+
writeConfig(desired, file);
|
|
180
|
+
expect(readConfigFile(file)).toEqual(desired);
|
|
181
|
+
expect(fs.readFileSync(file, 'utf8')).toContain('/* before */');
|
|
182
|
+
expect(fs.readFileSync(file, 'utf8')).toContain('/* after');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.each(['', '// notes without newline', '{"old":1}'])('supports new/empty/comment-only/JSON files (%s)', (text) => {
|
|
186
|
+
const file = path.join(globalDir, 'opencode.json');
|
|
187
|
+
if (text) put(file, text);
|
|
188
|
+
writeConfig({ model: 'new', nested: { enabled: true } }, file);
|
|
189
|
+
expect(readConfigFile(file)).toEqual({ model: 'new', nested: { enabled: true } });
|
|
190
|
+
if (text.startsWith('//')) expect(fs.readFileSync(file, 'utf8')).toContain(text);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it.each(['writeFileSync', 'copyFileSync', 'renameSync'])('keeps original content and cleans temporary files when %s fails', (method) => {
|
|
194
|
+
const original = '{ // retained\n "model":"old"\n}';
|
|
195
|
+
const file = put(path.join(globalDir, 'opencode.jsonc'), original);
|
|
196
|
+
vi.spyOn(fs, method).mockImplementation(() => { throw new Error('simulated IO failure'); });
|
|
197
|
+
expect(() => writeConfig({ model: 'new' }, file)).toThrow('simulated IO failure');
|
|
198
|
+
expect(fs.readFileSync(file, 'utf8')).toBe(original);
|
|
199
|
+
expect(fs.readdirSync(globalDir).some((name) => name.endsWith('.tmp'))).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -1179,7 +1179,6 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1179
1179
|
req.path.startsWith('/api/config/commands') ||
|
|
1180
1180
|
req.path.startsWith('/api/config/global') ||
|
|
1181
1181
|
req.path.startsWith('/api/config/mcp') ||
|
|
1182
|
-
req.path.startsWith('/api/config/snippets') ||
|
|
1183
1182
|
req.path.startsWith('/api/config/settings') ||
|
|
1184
1183
|
req.path.startsWith('/api/config/skills') ||
|
|
1185
1184
|
req.path.startsWith('/api/config/plugins') ||
|
|
@@ -310,21 +310,6 @@ describe('core-routes', () => {
|
|
|
310
310
|
expect(dependencies.gracefulShutdown).not.toHaveBeenCalled();
|
|
311
311
|
});
|
|
312
312
|
|
|
313
|
-
it('should parse JSON bodies for snippet config routes', async () => {
|
|
314
|
-
const app = express();
|
|
315
|
-
registerCommonRequestMiddleware(app, { express });
|
|
316
|
-
app.post('/api/config/snippets/example', (req, res) => {
|
|
317
|
-
res.json({ body: req.body });
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
const response = await request(app)
|
|
321
|
-
.post('/api/config/snippets/example')
|
|
322
|
-
.send({ content: 'Snippet body' })
|
|
323
|
-
.expect(200);
|
|
324
|
-
|
|
325
|
-
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
|
|
326
|
-
});
|
|
327
|
-
|
|
328
313
|
// Global raw config saves send { content: "<jsonc string>" }. Without this
|
|
329
314
|
// prefix in the JSON middleware allowlist, PUT arrives with an empty body and
|
|
330
315
|
// the handler rejects with "Configuration content must be a string".
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isSensitiveCredentialResponse(method: string, requestPath: string): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Credential list/create replies contain raw keys in v2. Keep them host-only. */
|
|
2
|
+
export function isSensitiveCredentialResponse(method, requestPath) {
|
|
3
|
+
let pathname;
|
|
4
|
+
try {
|
|
5
|
+
pathname = decodeURIComponent(new URL(requestPath, 'http://openchamber.invalid').pathname).replace(/\/+$/, '').toLowerCase();
|
|
6
|
+
} catch {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
const root = pathname === '/credential' || pathname === '/api/credential';
|
|
10
|
+
return root && ['GET', 'HEAD', 'POST'].includes(String(method).toUpperCase());
|
|
11
|
+
}
|
|
@@ -233,6 +233,9 @@ export async function ensurePinnedOpenCode2Cli(input = {}) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
const installedPath = await install({ ...input, version: pin });
|
|
236
|
-
const version = readVersion(installedPath)
|
|
236
|
+
const version = readVersion(installedPath);
|
|
237
|
+
if (version !== pin) {
|
|
238
|
+
throw new Error(`Installed OpenCode version mismatch: expected ${pin}, got ${version || 'unknown'}`);
|
|
239
|
+
}
|
|
237
240
|
return { path: installedPath, version, source: 'installed', installed: true };
|
|
238
241
|
}
|
|
@@ -33,7 +33,10 @@ export const resolveOpenCodeEnvConfig = (options = {}) => {
|
|
|
33
33
|
warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`);
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
// WHATWG URL removes explicit default ports; inspect the authority before normalization.
|
|
37
|
+
const authority = raw.match(/^https?:\/\/([^/?#]+)/i)?.[1] ?? '';
|
|
38
|
+
const explicitPort = authority.match(/:(\d+)$/)?.[1];
|
|
39
|
+
const port = parseInt(url.port || explicitPort || '', 10);
|
|
37
40
|
if (!Number.isFinite(port) || port <= 0) {
|
|
38
41
|
warnInvalidHost('must include an explicit port (example: http://hostname:4096)');
|
|
39
42
|
return null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolveOpenCodeEnvConfig } from './env-config.js';
|
|
3
|
+
|
|
4
|
+
describe('explicit OpenCode host ports', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['http://example.test:80', 'http://example.test', 80],
|
|
7
|
+
['https://example.test:443/', 'https://example.test', 443],
|
|
8
|
+
['http://[::1]:80', 'http://[::1]', 80],
|
|
9
|
+
['https://[::1]:443', 'https://[::1]', 443],
|
|
10
|
+
['http://[::1]:4096', 'http://[::1]:4096', 4096],
|
|
11
|
+
['https://example.test:80', 'https://example.test:80', 80],
|
|
12
|
+
])('preserves the effective port of %s', (host, origin, port) => {
|
|
13
|
+
const config = resolveOpenCodeEnvConfig({ env: { OPENCODE_HOST: host, OPENCODE_PORT: '1234' } });
|
|
14
|
+
expect(config.configuredOpenCodeHost).toEqual({ origin, port });
|
|
15
|
+
expect(config.effectivePort).toBe(port);
|
|
16
|
+
expect(new URL('/api/info', config.configuredOpenCodeHost.origin).href).toBe(`${origin}/api/info`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it.each(['http://example.test', 'https://example.test', 'http://[::1]', 'http://example.test:',
|
|
20
|
+
'ftp://example.test:80', 'http://example.test:80/path', 'http://example.test:80?q=1',
|
|
21
|
+
'http://example.test:80#hash', 'http://example.test:0', 'http://example.test:65536', 'invalid'])('retains rejection of %s', (host) => {
|
|
22
|
+
const logger = { warn: vi.fn() };
|
|
23
|
+
const config = resolveOpenCodeEnvConfig({ env: { OPENCODE_HOST: host, OPENCODE_PORT: '1234' }, logger });
|
|
24
|
+
expect(config.configuredOpenCodeHost).toBeNull();
|
|
25
|
+
expect(config.effectivePort).toBe(1234);
|
|
26
|
+
expect(logger.warn).toHaveBeenCalledOnce();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
5
|
import { createOpenCodeEnvRuntime } from './env-runtime.js';
|
|
6
|
+
import { PINNED_OPENCODE2_VERSION } from './opencode2-pin.js';
|
|
6
7
|
|
|
7
8
|
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
|
8
9
|
const originalComSpec = process.env.ComSpec;
|
|
@@ -305,7 +306,7 @@ describe('OpenCode env runtime', () => {
|
|
|
305
306
|
|
|
306
307
|
it('discovers a previously installed pin from the OpenChamber data dir', () => {
|
|
307
308
|
const dataDir = createTempDir('openchamber-installed-cli-');
|
|
308
|
-
const installedDir = path.join(dataDir, 'opencode-cli',
|
|
309
|
+
const installedDir = path.join(dataDir, 'opencode-cli', PINNED_OPENCODE2_VERSION);
|
|
309
310
|
const installedBinary = path.join(installedDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
310
311
|
fs.mkdirSync(installedDir, { recursive: true });
|
|
311
312
|
fs.writeFileSync(installedBinary, '#!/bin/sh\nexit 0\n');
|
|
@@ -5,6 +5,8 @@ import { registerSessionGoalRoutes } from '../session-goal/routes.js';
|
|
|
5
5
|
import { registerSessionMetadataRoutes } from '../session-metadata/routes.js';
|
|
6
6
|
import { registerGitHubRoutes } from '../github/routes.js';
|
|
7
7
|
import { registerGitRoutes } from '../git/routes.js';
|
|
8
|
+
import { makeOpenCodeV2Client } from './v2-client.js';
|
|
9
|
+
import { releaseDeletedWorktreeLocation } from './location-release.js';
|
|
8
10
|
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
|
|
9
11
|
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
|
|
10
12
|
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
|
|
@@ -31,7 +33,6 @@ import { getProviderSources, removeProviderConfig } from './providers.js';
|
|
|
31
33
|
import { getAgentSources, getAgentConfig, listDisabledAgentOverrides, createAgent, updateAgent, deleteAgent } from './agents.js';
|
|
32
34
|
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
|
|
33
35
|
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
|
|
34
|
-
import { listSnippets, getSnippet, createSnippet, updateSnippet, deleteSnippet, expandSnippets } from './snippets.js';
|
|
35
36
|
import {
|
|
36
37
|
listPluginEntries,
|
|
37
38
|
getPluginEntry,
|
|
@@ -359,12 +360,6 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
359
360
|
createMcpConfig,
|
|
360
361
|
updateMcpConfig,
|
|
361
362
|
deleteMcpConfig,
|
|
362
|
-
listSnippets,
|
|
363
|
-
getSnippet,
|
|
364
|
-
createSnippet,
|
|
365
|
-
updateSnippet,
|
|
366
|
-
deleteSnippet,
|
|
367
|
-
expandSnippets,
|
|
368
363
|
});
|
|
369
364
|
|
|
370
365
|
registerPluginRoutes(app, {
|
|
@@ -492,6 +487,13 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
492
487
|
registerGitRoutes(app, {
|
|
493
488
|
messageQueueService,
|
|
494
489
|
broadcastWorktreeTopologyChanged,
|
|
490
|
+
releaseWorktreeLocation: (directory) => releaseDeletedWorktreeLocation({
|
|
491
|
+
directory,
|
|
492
|
+
getClient: () => makeOpenCodeV2Client({
|
|
493
|
+
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
494
|
+
authHeaders: getOpenCodeAuthHeaders(),
|
|
495
|
+
}),
|
|
496
|
+
}),
|
|
495
497
|
});
|
|
496
498
|
registerMagicPromptRoutes(app, {
|
|
497
499
|
fsPromises,
|
|
@@ -35,9 +35,9 @@ export const createHmrStateRuntime = (dependencies) => {
|
|
|
35
35
|
if (typeof hmrState.userProvidedOpenCodePassword !== 'undefined') {
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
const initialPassword =
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const initialPassword = processLike.env.OPENCODE_PASSWORD?.trim()
|
|
39
|
+
|| processLike.env.OPENCODE_SERVER_PASSWORD?.trim()
|
|
40
|
+
|| '';
|
|
41
41
|
hmrState.userProvidedOpenCodePassword = initialPassword || null;
|
|
42
42
|
};
|
|
43
43
|
|
|
@@ -51,7 +51,7 @@ export const createHmrStateRuntime = (dependencies) => {
|
|
|
51
51
|
openCodeAuthPassword:
|
|
52
52
|
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
|
|
53
53
|
? hmrState.openCodeAuthPassword
|
|
54
|
-
: userProvidedOpenCodePassword,
|
|
54
|
+
: hmrState.openCodeAuthSource === 'shared-service' ? null : userProvidedOpenCodePassword,
|
|
55
55
|
openCodeAuthSource:
|
|
56
56
|
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
|
|
57
57
|
? hmrState.openCodeAuthSource
|
|
@@ -29,8 +29,6 @@ const OPENCODE_CHILD_SIGTERM_GRACE_MS = 2500;
|
|
|
29
29
|
const OPENCODE_CHILD_SIGKILL_WAIT_MS = 1000;
|
|
30
30
|
// Last-used directory plus recently opened projects — deeper tails are unlikely
|
|
31
31
|
// to be the user's first click and just add background work.
|
|
32
|
-
const WARMUP_DIRECTORY_LIMIT = 4;
|
|
33
|
-
const WARMUP_REQUEST_TIMEOUT_MS = 30000;
|
|
34
32
|
|
|
35
33
|
export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
36
34
|
const {
|
|
@@ -57,7 +55,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
57
55
|
getManagedOpenCodeShellEnvSnapshot,
|
|
58
56
|
managedCapabilitiesRuntime = null,
|
|
59
57
|
getActiveSessionCount = () => 0,
|
|
60
|
-
getWarmupDirectories = async () => [],
|
|
61
58
|
// Official background service (shared-service.js). null keeps the private
|
|
62
59
|
// managed `opencode serve` path.
|
|
63
60
|
sharedService = null,
|
|
@@ -796,6 +793,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
796
793
|
...shellEnv,
|
|
797
794
|
...process.env,
|
|
798
795
|
PATH: envPath,
|
|
796
|
+
OPENCODE_PASSWORD: openCodePassword,
|
|
799
797
|
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
|
800
798
|
};
|
|
801
799
|
const managedEnv = managedCapabilitiesRuntime
|
|
@@ -1253,11 +1251,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
1253
1251
|
await waitForOpenCodePort();
|
|
1254
1252
|
try {
|
|
1255
1253
|
await waitForOpenCodeReady();
|
|
1256
|
-
// Warm only after readiness admits; a failed gate must not kick off
|
|
1257
|
-
// directory-scoped traffic against an unready upstream.
|
|
1258
|
-
if (state.isOpenCodeReady) {
|
|
1259
|
-
void warmOpenCodeDirectories();
|
|
1260
|
-
}
|
|
1261
1254
|
} catch (error) {
|
|
1262
1255
|
console.error(`OpenCode readiness check failed: ${error.message}`);
|
|
1263
1256
|
}
|
|
@@ -1268,47 +1261,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
1268
1261
|
}
|
|
1269
1262
|
};
|
|
1270
1263
|
|
|
1271
|
-
// OpenCode initializes each project directory lazily on its first
|
|
1272
|
-
// directory-scoped request, and that initialization takes seconds on large
|
|
1273
|
-
// session stores. Without warming, the user's first session open pays it
|
|
1274
|
-
// interactively. Warm the most recently used directories right after
|
|
1275
|
-
// readiness so the work overlaps UI startup instead. Sequential and
|
|
1276
|
-
// best-effort: a failed or slow directory never blocks the others for long,
|
|
1277
|
-
// and a restart invalidates the pass via the port/readiness guard.
|
|
1278
|
-
const warmOpenCodeDirectories = async () => {
|
|
1279
|
-
let directories = [];
|
|
1280
|
-
try {
|
|
1281
|
-
directories = await getWarmupDirectories();
|
|
1282
|
-
} catch {
|
|
1283
|
-
return;
|
|
1284
|
-
}
|
|
1285
|
-
if (!Array.isArray(directories) || directories.length === 0) return;
|
|
1286
|
-
|
|
1287
|
-
const warmedPort = state.openCodePort;
|
|
1288
|
-
for (const directory of directories.slice(0, WARMUP_DIRECTORY_LIMIT)) {
|
|
1289
|
-
if (typeof directory !== 'string' || !directory) continue;
|
|
1290
|
-
if (!state.isOpenCodeReady || state.openCodePort !== warmedPort) return;
|
|
1291
|
-
let timeout = null;
|
|
1292
|
-
try {
|
|
1293
|
-
const controller = new AbortController();
|
|
1294
|
-
timeout = setTimeout(() => controller.abort(), WARMUP_REQUEST_TIMEOUT_MS);
|
|
1295
|
-
// Warming a directory is the point, not the answer: any directory-scoped
|
|
1296
|
-
// read makes OpenCode initialise it. `/api/session` is the cheapest one
|
|
1297
|
-
// that takes a directory.
|
|
1298
|
-
const url = `${buildOpenCodeUrl('/api/session', '')}?directory=${encodeURIComponent(directory)}&limit=1`;
|
|
1299
|
-
await fetch(url, {
|
|
1300
|
-
method: 'GET',
|
|
1301
|
-
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
1302
|
-
signal: controller.signal,
|
|
1303
|
-
});
|
|
1304
|
-
} catch {
|
|
1305
|
-
// Best-effort — the directory stays lazy and the UI's own request warms it.
|
|
1306
|
-
} finally {
|
|
1307
|
-
if (timeout) clearTimeout(timeout);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
};
|
|
1311
|
-
|
|
1312
1264
|
const bootstrapOpenCodeAtStartup = () => {
|
|
1313
1265
|
if (!bootstrapOpenCodePromise) {
|
|
1314
1266
|
bootstrapOpenCodePromise = runBootstrapOpenCodeAtStartup().finally(() => {
|
|
@@ -10,6 +10,8 @@ vi.mock('node:child_process', () => ({
|
|
|
10
10
|
|
|
11
11
|
const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
|
|
12
12
|
const { createOpenCodeNetworkRuntime } = await import('./network-runtime.js');
|
|
13
|
+
const { resolveOpenCodeEnvConfig } = await import('./env-config.js');
|
|
14
|
+
const { RUNTIME_CONTRACT_MIN_VERIFIED } = await import('./runtime-contract.js');
|
|
13
15
|
|
|
14
16
|
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
|
15
17
|
const originalPath = process.env.PATH;
|
|
@@ -106,6 +108,7 @@ const createRuntime = (overrides = {}, stateRef = null) => {
|
|
|
106
108
|
getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({
|
|
107
109
|
PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin',
|
|
108
110
|
SHELL_ONLY: 'yes',
|
|
111
|
+
OPENCODE_PASSWORD: 'shell-v2-password',
|
|
109
112
|
OPENCODE_SERVER_PASSWORD: 'shell-password',
|
|
110
113
|
})),
|
|
111
114
|
...overrides,
|
|
@@ -160,6 +163,28 @@ const startListeningChild = () => {
|
|
|
160
163
|
};
|
|
161
164
|
|
|
162
165
|
describe('OpenCode lifecycle', () => {
|
|
166
|
+
it.each(['http://example.test:80', 'https://example.test:443', 'http://[::1]:80'])('attaches explicit default-port host %s without spawning', async (host) => {
|
|
167
|
+
const config = resolveOpenCodeEnvConfig({ env: { OPENCODE_HOST: host, OPENCODE_PORT: '9999' } });
|
|
168
|
+
const stateRef = {};
|
|
169
|
+
const runtime = createRuntime({
|
|
170
|
+
env: {
|
|
171
|
+
ENV_CONFIGURED_OPENCODE_PORT: config.configuredOpenCodePort,
|
|
172
|
+
ENV_CONFIGURED_OPENCODE_HOST: config.configuredOpenCodeHost,
|
|
173
|
+
ENV_EFFECTIVE_PORT: config.effectivePort,
|
|
174
|
+
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
|
|
175
|
+
ENV_SKIP_OPENCODE_START: true,
|
|
176
|
+
},
|
|
177
|
+
buildOpenCodeUrl: (route) => new URL(route, config.configuredOpenCodeHost.origin).href,
|
|
178
|
+
}, stateRef);
|
|
179
|
+
const fetchMock = stubOpenCodeFetch({ health: { ...DEFAULT_V2_HEALTH, version: '2.0.23' } });
|
|
180
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
181
|
+
await runtime.waitForOpenCodeReady(1000, 1);
|
|
182
|
+
expect(stateRef.current.isExternalOpenCode).toBe(true);
|
|
183
|
+
expect(stateRef.current.openCodePort).toBe(config.effectivePort);
|
|
184
|
+
expect(fetchMock.mock.calls.some(([url]) => String(url) === `${config.configuredOpenCodeHost.origin}/api/info`)).toBe(true);
|
|
185
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
163
188
|
it('launches managed OpenCode with the managed PATH', async () => {
|
|
164
189
|
delete process.env.OPENCODE_BINARY;
|
|
165
190
|
const child = createMockChild();
|
|
@@ -179,6 +204,7 @@ describe('OpenCode lifecycle', () => {
|
|
|
179
204
|
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
|
|
180
205
|
expect(options.env.SHELL_ONLY).toBe('yes');
|
|
181
206
|
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
|
|
207
|
+
expect(options.env.OPENCODE_PASSWORD).toBe('password');
|
|
182
208
|
|
|
183
209
|
await server.close();
|
|
184
210
|
});
|
|
@@ -847,7 +873,7 @@ describe('OpenCode lifecycle', () => {
|
|
|
847
873
|
stateRef.current.runtimeContract = {
|
|
848
874
|
executionAllowed: true,
|
|
849
875
|
phase: 'ready',
|
|
850
|
-
serveVersion:
|
|
876
|
+
serveVersion: RUNTIME_CONTRACT_MIN_VERIFIED,
|
|
851
877
|
instanceGeneration: 1,
|
|
852
878
|
};
|
|
853
879
|
|
|
@@ -861,7 +887,7 @@ describe('OpenCode lifecycle', () => {
|
|
|
861
887
|
runtime.refreshRuntimeContractFromProbe(
|
|
862
888
|
{
|
|
863
889
|
ok: true,
|
|
864
|
-
version:
|
|
890
|
+
version: RUNTIME_CONTRACT_MIN_VERIFIED,
|
|
865
891
|
authenticated: true,
|
|
866
892
|
healthOk: true,
|
|
867
893
|
},
|
|
@@ -875,7 +901,7 @@ describe('OpenCode lifecycle', () => {
|
|
|
875
901
|
runtime.refreshRuntimeContractFromProbe(
|
|
876
902
|
{
|
|
877
903
|
ok: true,
|
|
878
|
-
version:
|
|
904
|
+
version: RUNTIME_CONTRACT_MIN_VERIFIED,
|
|
879
905
|
authenticated: true,
|
|
880
906
|
healthOk: true,
|
|
881
907
|
},
|
|
@@ -884,7 +910,7 @@ describe('OpenCode lifecycle', () => {
|
|
|
884
910
|
);
|
|
885
911
|
expect(stateRef.current.runtimeContract.executionAllowed).toBe(true);
|
|
886
912
|
expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
|
|
887
|
-
expect(stateRef.current.runtimeContract.serveVersion).toBe(
|
|
913
|
+
expect(stateRef.current.runtimeContract.serveVersion).toBe(RUNTIME_CONTRACT_MIN_VERIFIED);
|
|
888
914
|
});
|
|
889
915
|
|
|
890
916
|
it('does not mark external skip-start ready before version and migration admit', async () => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OpenCodeClient } from '@opencode/client';
|
|
2
|
+
|
|
3
|
+
export type LocationReleaseResult = { state: 'released' | 'timeout' | 'failed' | 'unavailable' };
|
|
4
|
+
export function releaseDeletedWorktreeLocation(input: {
|
|
5
|
+
getClient: () => OpenCodeClient | null;
|
|
6
|
+
directory: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}): Promise<LocationReleaseResult>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit worktree deletion only. An eviction acknowledgement detaches cached
|
|
3
|
+
* services; it does not prove that borrowers have released every OS handle.
|
|
4
|
+
* Never use this operation for automatic navigation cleanup on a shared serve.
|
|
5
|
+
*
|
|
6
|
+
* @param {{ getClient: () => import('@opencode/client').OpenCodeClient | null, directory: string, timeoutMs?: number }} input
|
|
7
|
+
* @returns {Promise<{ state: 'released' | 'timeout' | 'failed' | 'unavailable' }>}
|
|
8
|
+
*/
|
|
9
|
+
export async function releaseDeletedWorktreeLocation({ getClient, directory, timeoutMs = 2000 }) {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
let timer;
|
|
12
|
+
try {
|
|
13
|
+
if (!directory || !directory.trim()) return { state: 'unavailable' };
|
|
14
|
+
const client = getClient();
|
|
15
|
+
if (!client) return { state: 'unavailable' };
|
|
16
|
+
const deadline = new Promise((resolve) => {
|
|
17
|
+
timer = setTimeout(() => {
|
|
18
|
+
controller.abort();
|
|
19
|
+
resolve({ state: 'timeout' });
|
|
20
|
+
}, timeoutMs);
|
|
21
|
+
});
|
|
22
|
+
const request = Promise.resolve().then(() => client.debug.location.evict(
|
|
23
|
+
{ location: { directory } },
|
|
24
|
+
{ signal: controller.signal },
|
|
25
|
+
)).then(() => ({ state: 'released' }), () => ({ state: controller.signal.aborted ? 'timeout' : 'failed' }));
|
|
26
|
+
return await Promise.race([request, deadline]);
|
|
27
|
+
} catch {
|
|
28
|
+
return { state: 'failed' };
|
|
29
|
+
} finally {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OpenCode } from '@opencode/client';
|
|
3
|
+
import { releaseDeletedWorktreeLocation } from './location-release.js';
|
|
4
|
+
|
|
5
|
+
afterEach(() => vi.useRealTimers());
|
|
6
|
+
|
|
7
|
+
describe('explicit worktree location release', () => {
|
|
8
|
+
it('uses the official SDK exact target, current origin and auth', async () => {
|
|
9
|
+
const requests = [];
|
|
10
|
+
let origin = 'https://old.invalid';
|
|
11
|
+
const getClient = () => OpenCode.make({ baseUrl: origin, headers: { Authorization: 'test-auth' }, fetch: async (input, init) => {
|
|
12
|
+
requests.push(new Request(input, init));
|
|
13
|
+
return new Response(null, { status: 204 });
|
|
14
|
+
} });
|
|
15
|
+
origin = 'https://current.invalid';
|
|
16
|
+
expect(await releaseDeletedWorktreeLocation({ getClient, directory: '/repo/work tree' })).toEqual({ state: 'released' });
|
|
17
|
+
expect(requests).toHaveLength(1);
|
|
18
|
+
const url = new URL(requests[0].url);
|
|
19
|
+
expect(url.origin + url.pathname).toBe('https://current.invalid/api/debug/location');
|
|
20
|
+
expect(url.searchParams.get('location[directory]')).toBe('/repo/work tree');
|
|
21
|
+
expect(requests[0].method).toBe('DELETE');
|
|
22
|
+
expect(requests[0].headers.get('Authorization')).toBe('test-auth');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports failure without claiming successful disposal', async () => {
|
|
26
|
+
const getClient = () => ({ debug: { location: { evict: async () => { throw new Error('unavailable'); } } } });
|
|
27
|
+
expect(await releaseDeletedWorktreeLocation({ getClient, directory: '/repo' })).toEqual({ state: 'failed' });
|
|
28
|
+
expect(await releaseDeletedWorktreeLocation({ getClient: () => null, directory: '/repo' })).toEqual({ state: 'unavailable' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('bounds even a transport that ignores abort and never settles', async () => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
let signal;
|
|
34
|
+
const getClient = () => ({ debug: { location: { evict: (_input, options) => {
|
|
35
|
+
signal = options.signal;
|
|
36
|
+
return new Promise(() => {});
|
|
37
|
+
} } } });
|
|
38
|
+
const result = releaseDeletedWorktreeLocation({ getClient, directory: '/repo', timeoutMs: 50 });
|
|
39
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
40
|
+
expect(await result).toEqual({ state: 'timeout' });
|
|
41
|
+
expect(signal.aborted).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|