@vantran-se/openchamber-web 2.0.4 → 2.0.6
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/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
- package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
- package/dist/assets/FilesView-DC2gadXM.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
- package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
- package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
- package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
- package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
- package/dist/assets/de-ByfQDcPj.js +10 -0
- package/dist/assets/es-DzNbjsQS.js +16 -0
- package/dist/assets/fr-B8AVLrnX.js +14 -0
- package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
- package/dist/assets/ja-1g6S0G19.js +16 -0
- package/dist/assets/ko-CxplvYkJ.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
- package/dist/assets/main-GjXmVNpD.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
- package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
- package/dist/assets/pl-f3QyqvIB.js +16 -0
- package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
- package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
- package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
- package/dist/assets/tr-BNFsa6nz.js +7 -0
- package/dist/assets/uk-CJ9y5ULA.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
- package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
- package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +4 -4
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- package/dist/assets/zh-TW-C6oelpnL.js +0 -16
|
@@ -42,6 +42,19 @@ describe('project directory runtime', () => {
|
|
|
42
42
|
expect(result).toEqual({ ok: true, directory: '/real/path/to/project', requestedDirectory: '/symlink/path/to/project' });
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
it('refuses a directory the host says no to, before it looks at the disk', async () => {
|
|
46
|
+
let statCalls = 0;
|
|
47
|
+
const runtime = createTestRuntime({
|
|
48
|
+
fsPromises: { stat: async () => { statCalls += 1; return { isDirectory: () => true }; }, realpath: async (p) => p },
|
|
49
|
+
refuseDirectory: (candidate) => (candidate.startsWith('/spaces/') ? 'A directory under /spaces/ belongs to an isolated space' : null),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(await runtime.validateDirectoryPath('/spaces/a1b2c3d4e5f6/repo')).toEqual({ ok: false, error: 'A directory under /spaces/ belongs to an isolated space' });
|
|
53
|
+
expect(statCalls).toBe(0);
|
|
54
|
+
expect(await runtime.validateDirectoryPath('/home/user/project')).toMatchObject({ ok: true });
|
|
55
|
+
expect(statCalls).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
45
58
|
it('returns error when candidate is empty', async () => {
|
|
46
59
|
const runtime = createTestRuntime();
|
|
47
60
|
const result = await runtime.validateDirectoryPath('');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { registerOpenCodeRoutes } from './routes.js';
|
|
5
|
+
|
|
6
|
+
const createApp = (getProviderSources) => {
|
|
7
|
+
const app = express();
|
|
8
|
+
app.use(express.json());
|
|
9
|
+
registerOpenCodeRoutes(app, {
|
|
10
|
+
resolveProjectDirectory: vi.fn(async () => ({ directory: '/projects/app' })),
|
|
11
|
+
getProviderSources,
|
|
12
|
+
});
|
|
13
|
+
return app;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('GET /api/provider/:providerId/source', () => {
|
|
17
|
+
it('returns the stored config entry next to the layer sources', async () => {
|
|
18
|
+
const stored = {
|
|
19
|
+
name: 'Stored',
|
|
20
|
+
package: 'aisdk:@ai-sdk/openai-compatible',
|
|
21
|
+
env: ['STORED_KEY'],
|
|
22
|
+
settings: { baseURL: 'https://stored.example.com/v1' },
|
|
23
|
+
models: { m: { modelID: 'm', name: 'M' } },
|
|
24
|
+
};
|
|
25
|
+
const getProviderSources = vi.fn(() => ({
|
|
26
|
+
sources: {
|
|
27
|
+
auth: { exists: false },
|
|
28
|
+
user: { exists: false, path: '/user.json' },
|
|
29
|
+
project: { exists: true, path: '/projects/app/opencode.json' },
|
|
30
|
+
custom: { exists: false, path: null },
|
|
31
|
+
},
|
|
32
|
+
config: stored,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const response = await request(createApp(getProviderSources))
|
|
36
|
+
.get('/api/provider/stored-llm/source?directory=/projects/app')
|
|
37
|
+
.expect(200);
|
|
38
|
+
|
|
39
|
+
expect(getProviderSources).toHaveBeenCalledWith('stored-llm', '/projects/app');
|
|
40
|
+
expect(response.body.config).toEqual(stored);
|
|
41
|
+
expect(response.body.sources.project.exists).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns null config when no layer defines the provider', async () => {
|
|
45
|
+
const response = await request(createApp(() => ({
|
|
46
|
+
sources: {
|
|
47
|
+
auth: { exists: false },
|
|
48
|
+
user: { exists: false, path: '/user.json' },
|
|
49
|
+
project: { exists: false, path: null },
|
|
50
|
+
custom: { exists: false, path: null },
|
|
51
|
+
},
|
|
52
|
+
config: null,
|
|
53
|
+
})))
|
|
54
|
+
.get('/api/provider/other/source')
|
|
55
|
+
.expect(200);
|
|
56
|
+
|
|
57
|
+
expect(response.body.config).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
deleteSectionEntry,
|
|
12
12
|
toModelVariants,
|
|
13
13
|
toProviderEntity,
|
|
14
|
+
readStoredProviderEntry,
|
|
14
15
|
toProviderPackage,
|
|
15
16
|
toNpmPackage,
|
|
16
17
|
} from './config-v2.js';
|
|
@@ -43,6 +44,8 @@ function getProviderSources(providerId, workingDirectory) {
|
|
|
43
44
|
project: { exists: providerExistsIn(projectConfig, providerId), path: paths.projectPath || null },
|
|
44
45
|
custom: { exists: providerExistsIn(customConfig, providerId), path: paths.customPath },
|
|
45
46
|
},
|
|
47
|
+
// Same precedence as the edit scope: custom > project > user.
|
|
48
|
+
config: readStoredProviderEntry([customConfig, projectConfig, userConfig], providerId),
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -436,6 +436,68 @@ describe('custom provider config persistence', () => {
|
|
|
436
436
|
}
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
+
test('getProviderSources returns the stored entry from a JSONC config, v2 shape, without the key', () => {
|
|
440
|
+
const configPath = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
|
441
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
442
|
+
fs.writeFileSync(configPath, `{
|
|
443
|
+
// v1 spelling, still decoded by OpenCode 2
|
|
444
|
+
"provider": {
|
|
445
|
+
"stored-llm": {
|
|
446
|
+
"name": "Stored",
|
|
447
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
448
|
+
"env": ["STORED_KEY"],
|
|
449
|
+
"options": { "baseURL": "https://stored.example.com/v1", "apiKey": "sk-secret", "timeout": 5 },
|
|
450
|
+
"models": { "m": { "name": "M", "variants": { "high": { "reasoningEffort": "high" } } } },
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
}`, 'utf8');
|
|
454
|
+
|
|
455
|
+
const { config } = getProviderSources('stored-llm', projectDir);
|
|
456
|
+
expect(config).toEqual({
|
|
457
|
+
name: 'Stored',
|
|
458
|
+
package: 'aisdk:@ai-sdk/openai-compatible',
|
|
459
|
+
env: ['STORED_KEY'],
|
|
460
|
+
settings: { baseURL: 'https://stored.example.com/v1', timeout: 5 },
|
|
461
|
+
models: { m: { name: 'M', variants: [{ id: 'high', settings: { reasoningEffort: 'high' } }] } },
|
|
462
|
+
});
|
|
463
|
+
expect(getProviderSources('missing-llm', projectDir).config).toBeNull();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('saving the stored entry back keeps env, settings and model fields it does not edit', () => {
|
|
467
|
+
const providerId = 'keep-llm';
|
|
468
|
+
const configPath = path.join(projectDir, '.opencode', 'opencode.json');
|
|
469
|
+
writeJson(configPath, {
|
|
470
|
+
providers: {
|
|
471
|
+
[providerId]: {
|
|
472
|
+
name: 'Keep',
|
|
473
|
+
package: 'aisdk:@ai-sdk/openai-compatible',
|
|
474
|
+
env: ['KEEP_KEY'],
|
|
475
|
+
settings: { baseURL: 'https://keep.example.com/v1', timeout: 5 },
|
|
476
|
+
body: { store: false },
|
|
477
|
+
models: { m: { modelID: 'm', name: 'M', limit: { context: 1000 } } },
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const { config } = getProviderSources(providerId, projectDir);
|
|
483
|
+
upsertProviderConfig(providerId, {
|
|
484
|
+
name: config.name,
|
|
485
|
+
package: config.package,
|
|
486
|
+
env: config.env,
|
|
487
|
+
settings: { baseURL: config.settings.baseURL },
|
|
488
|
+
models: { m: { name: 'M renamed' } },
|
|
489
|
+
}, projectDir, 'project', { hasStoredAuth: true });
|
|
490
|
+
|
|
491
|
+
expect(readJson(configPath).providers[providerId]).toEqual({
|
|
492
|
+
name: 'Keep',
|
|
493
|
+
package: 'aisdk:@ai-sdk/openai-compatible',
|
|
494
|
+
env: ['KEEP_KEY'],
|
|
495
|
+
settings: { baseURL: 'https://keep.example.com/v1', timeout: 5 },
|
|
496
|
+
body: { store: false },
|
|
497
|
+
models: { m: { modelID: 'm', name: 'M renamed', limit: { context: 1000 } } },
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
439
501
|
test('custom-scope edit updates custom layer without creating a user entry', () => {
|
|
440
502
|
const providerId = `custom-scope-${Date.now()}`;
|
|
441
503
|
const customPath = path.join(projectDir, 'custom-opencode.json');
|
|
@@ -236,7 +236,7 @@ const SESSION_LIST_ALLOWED_FIELDS = [
|
|
|
236
236
|
'fork',
|
|
237
237
|
];
|
|
238
238
|
|
|
239
|
-
const sanitizeSessionListItem = (session) => {
|
|
239
|
+
export const sanitizeSessionListItem = (session) => {
|
|
240
240
|
if (!session || typeof session !== 'object' || Array.isArray(session)) {
|
|
241
241
|
return session;
|
|
242
242
|
}
|
|
@@ -307,6 +307,11 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
307
307
|
// migrated.
|
|
308
308
|
getArchivedSessions = null,
|
|
309
309
|
getStoredSessionMetadata = null,
|
|
310
|
+
// Isolated spaces, when the feature's switch is on: the merged session list, and the hub
|
|
311
|
+
// whose space events the global SSE stream carries beside the host's. Both absent means
|
|
312
|
+
// the host's own answers go out exactly as before spaces.
|
|
313
|
+
mergeSpaceSessionList = null,
|
|
314
|
+
spaceEventHub = null,
|
|
310
315
|
} = deps;
|
|
311
316
|
|
|
312
317
|
/**
|
|
@@ -547,6 +552,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
547
552
|
let heartbeatTimer = null;
|
|
548
553
|
let upstreamStallTimer = null;
|
|
549
554
|
let didUpstreamStall = false;
|
|
555
|
+
let unsubscribeSpaceEvents = null;
|
|
550
556
|
let writeQueue = Promise.resolve(true);
|
|
551
557
|
const sseBoundary = createSseBoundaryTracker();
|
|
552
558
|
|
|
@@ -641,6 +647,26 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
641
647
|
return writeQueue;
|
|
642
648
|
};
|
|
643
649
|
|
|
650
|
+
// The events of isolated spaces ride the global stream too, one block each, written
|
|
651
|
+
// only between the upstream's own blocks so a block of the host's is never cut.
|
|
652
|
+
// A directory in the query or in the header scopes the stream to the host's one directory.
|
|
653
|
+
const isGlobalStream = !new URL(requestUrl, 'http://localhost').searchParams.get('directory') && !req.get('x-opencode-directory');
|
|
654
|
+
const pendingSpaceBlocks = [];
|
|
655
|
+
const flushSpaceBlocks = async () => {
|
|
656
|
+
while (pendingSpaceBlocks.length > 0 && sseBoundary.isAtBoundary() && !abortController.signal.aborted) {
|
|
657
|
+
const canContinue = await enqueueSseWrite(pendingSpaceBlocks.shift());
|
|
658
|
+
if (!canContinue) return false;
|
|
659
|
+
}
|
|
660
|
+
return true;
|
|
661
|
+
};
|
|
662
|
+
if (spaceEventHub && isGlobalStream) {
|
|
663
|
+
unsubscribeSpaceEvents = spaceEventHub.subscribeEvent((event) => {
|
|
664
|
+
if (event.spaceId === null) return;
|
|
665
|
+
pendingSpaceBlocks.push(`data: ${JSON.stringify(event.payload)}\n\n`);
|
|
666
|
+
void flushSpaceBlocks();
|
|
667
|
+
}, { spaces: true });
|
|
668
|
+
}
|
|
669
|
+
|
|
644
670
|
scheduleHeartbeat();
|
|
645
671
|
resetUpstreamStallTimer();
|
|
646
672
|
|
|
@@ -657,6 +683,9 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
657
683
|
if (!canContinue) {
|
|
658
684
|
break;
|
|
659
685
|
}
|
|
686
|
+
if (!await flushSpaceBlocks()) {
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
660
689
|
}
|
|
661
690
|
}
|
|
662
691
|
|
|
@@ -676,6 +705,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
676
705
|
res.end();
|
|
677
706
|
}
|
|
678
707
|
} finally {
|
|
708
|
+
unsubscribeSpaceEvents?.();
|
|
679
709
|
if (heartbeatTimer) {
|
|
680
710
|
clearTimeout(heartbeatTimer);
|
|
681
711
|
heartbeatTimer = null;
|
|
@@ -760,7 +790,13 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
760
790
|
}
|
|
761
791
|
|
|
762
792
|
res.setHeader('content-type', result.contentType);
|
|
763
|
-
|
|
793
|
+
const hostList = await overlayOwnedStateOnList(sanitizeSessionListPayload(result.payload));
|
|
794
|
+
// The first page of the global list carries every space's sessions after the host's; a
|
|
795
|
+
// later page, and a list scoped to one directory, are the host's alone.
|
|
796
|
+
const listQuery = new URL(upstreamPath, 'http://localhost').searchParams;
|
|
797
|
+
const scopedToDirectory = Boolean(listQuery.get('directory') || req.get('x-opencode-directory'));
|
|
798
|
+
const wantsSpaces = typeof mergeSpaceSessionList === 'function' && !listQuery.get('cursor') && !scopedToDirectory;
|
|
799
|
+
res.json(wantsSpaces ? await mergeSpaceSessionList(hostList) : hostList);
|
|
764
800
|
} catch (error) {
|
|
765
801
|
if (isAbortError(error)) {
|
|
766
802
|
return;
|
|
@@ -86,7 +86,7 @@ describe('OpenCode upgrade routes', () => {
|
|
|
86
86
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
it.each(['external', 'unavailable'
|
|
89
|
+
it.each(['external', 'unavailable'])('rejects %s on the server', async (reason) => {
|
|
90
90
|
const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => ({ supported: false, reason }) });
|
|
91
91
|
await request(app).post('/api/opencode/upgrade').send({}).expect(409);
|
|
92
92
|
expect(dependencies.upgradeOpenCodeCli).not.toHaveBeenCalled();
|
|
@@ -10,7 +10,7 @@ import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
|
|
|
10
10
|
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
|
11
11
|
import { settingsSurfaceOf } from './settings-files.js';
|
|
12
12
|
import { parseWebSearchSelection } from './config-v2.js';
|
|
13
|
-
import { getWebSearchSource, setWebSearchSelection } from './websearch-config.js';
|
|
13
|
+
import { getWebSearchSource, setWarmingEnabled, setWebSearchSelection } from './websearch-config.js';
|
|
14
14
|
|
|
15
15
|
export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
16
16
|
const {
|
|
@@ -287,6 +287,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
287
287
|
return res.json({
|
|
288
288
|
providerId,
|
|
289
289
|
sources: sources.sources,
|
|
290
|
+
config: sources.config,
|
|
290
291
|
});
|
|
291
292
|
} catch (error) {
|
|
292
293
|
console.error('Failed to get provider sources:', error);
|
|
@@ -379,6 +380,22 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
379
380
|
}
|
|
380
381
|
});
|
|
381
382
|
|
|
383
|
+
// Session warming (`warming` in OpenCode config), written like the web
|
|
384
|
+
// search choice above. Settings reads the effective value from OpenCode.
|
|
385
|
+
app.put('/api/config/warming', (req, res) => {
|
|
386
|
+
const enabled = req.body?.enabled;
|
|
387
|
+
if (typeof enabled !== 'boolean') {
|
|
388
|
+
return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const result = setWarmingEnabled(enabled);
|
|
392
|
+
return res.json({ success: true, changed: result.changed });
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error('Failed to save session warming:', error);
|
|
395
|
+
return res.status(500).json({ error: error.message || 'Failed to save session warming' });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
382
399
|
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
|
383
400
|
try {
|
|
384
401
|
const { providerId } = req.params;
|
|
@@ -528,6 +545,24 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
528
545
|
return res.status(413).json({ error: `Content exceeds maximum size of ${MAX_BEHAVIOR_PROMPT_SIZE} bytes` });
|
|
529
546
|
}
|
|
530
547
|
|
|
548
|
+
// `expectedContent` is what the editor loaded (null: no file). A file
|
|
549
|
+
// changed on disk since then is not overwritten with the stale copy.
|
|
550
|
+
if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'expectedContent')) {
|
|
551
|
+
const expected = req.body.expectedContent;
|
|
552
|
+
let current = null;
|
|
553
|
+
try {
|
|
554
|
+
current = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
557
|
+
}
|
|
558
|
+
if (current !== expected) {
|
|
559
|
+
return res.status(409).json({
|
|
560
|
+
error: 'AGENTS.md changed on disk since it was loaded',
|
|
561
|
+
code: 'AGENTS_MD_CONFLICT',
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
531
566
|
// Ensure parent directory exists
|
|
532
567
|
const parentDir = path.dirname(AGENTS_MD_PATH);
|
|
533
568
|
try {
|
|
@@ -23,6 +23,8 @@ export const createServerUtilsRuntime = (dependencies) => {
|
|
|
23
23
|
clearLastOpenCodeError,
|
|
24
24
|
getLoginShellPath,
|
|
25
25
|
getArchivedSessions = null,
|
|
26
|
+
getMergeSpaceSessionList = null,
|
|
27
|
+
getSpaceEventHub = null,
|
|
26
28
|
getStoredSessionMetadata = null,
|
|
27
29
|
} = dependencies;
|
|
28
30
|
|
|
@@ -221,6 +223,9 @@ export const createServerUtilsRuntime = (dependencies) => {
|
|
|
221
223
|
getUiNotificationClients,
|
|
222
224
|
getArchivedSessions,
|
|
223
225
|
getStoredSessionMetadata,
|
|
226
|
+
// Read when the proxy is set up, after `main` decided whether the spaces host exists.
|
|
227
|
+
mergeSpaceSessionList: typeof getMergeSpaceSessionList === 'function' ? getMergeSpaceSessionList() : null,
|
|
228
|
+
spaceEventHub: typeof getSpaceEventHub === 'function' ? getSpaceEventHub() : null,
|
|
224
229
|
});
|
|
225
230
|
};
|
|
226
231
|
|
|
@@ -65,6 +65,7 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
65
65
|
const SIDEBAR_PROJECT_DISPLAY_MODE_VALUES = new Set(['all', 'single']);
|
|
66
66
|
const SIDEBAR_VIEW_MODE_VALUES = new Set(['projects', 'timeline']);
|
|
67
67
|
const SIDEBAR_PROJECT_SORT_ORDER_VALUES = new Set(['manual', 'a-z', 'z-a', 'date-added', 'recent']);
|
|
68
|
+
const SIDEBAR_WORKTREE_SORT_ORDER_VALUES = new Set(['recent', 'manual', 'a-z']);
|
|
68
69
|
const HIDDEN_MODELS_MAX = 1024;
|
|
69
70
|
const RECENT_EFFORTS_MAX_KEYS = 128;
|
|
70
71
|
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
|
|
@@ -302,6 +303,9 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
302
303
|
if (SIDEBAR_PROJECT_SORT_ORDER_VALUES.has(candidate.sidebarProjectSortOrder)) {
|
|
303
304
|
result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
|
|
304
305
|
}
|
|
306
|
+
if (SIDEBAR_WORKTREE_SORT_ORDER_VALUES.has(candidate.sidebarWorktreeSortOrder)) {
|
|
307
|
+
result.sidebarWorktreeSortOrder = candidate.sidebarWorktreeSortOrder;
|
|
308
|
+
}
|
|
305
309
|
if (typeof candidate.sidebarShowRecentSection === 'boolean') {
|
|
306
310
|
result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
|
|
307
311
|
}
|
|
@@ -607,6 +611,12 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
607
611
|
if (typeof candidate.agentMemoryToolEnabled === 'boolean') {
|
|
608
612
|
result.agentMemoryToolEnabled = candidate.agentMemoryToolEnabled;
|
|
609
613
|
}
|
|
614
|
+
if (typeof candidate.agentNotifyToolEnabled === 'boolean') {
|
|
615
|
+
result.agentNotifyToolEnabled = candidate.agentNotifyToolEnabled;
|
|
616
|
+
}
|
|
617
|
+
if (typeof candidate.isolatedSpacesEnabled === 'boolean') {
|
|
618
|
+
result.isolatedSpacesEnabled = candidate.isolatedSpacesEnabled;
|
|
619
|
+
}
|
|
610
620
|
if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
|
|
611
621
|
const version = candidate.openCodeUpdateToastDismissedVersion.trim();
|
|
612
622
|
result.openCodeUpdateToastDismissedVersion = version.slice(0, VERSION_STRING_MAX_LENGTH);
|
|
@@ -764,13 +764,13 @@ describe('settings registry gate', () => {
|
|
|
764
764
|
desktopLanAccessEnabled: true, desktopKeepAwakeEnabled: true, desktopMinimizeToTrayEnabled: true, desktopMacMenuBarEnabled: true,
|
|
765
765
|
desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
|
|
766
766
|
defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
|
|
767
|
-
agentControlToolEnabled: true, agentWebToolEnabled: true, browserProvider: 'builtin', agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
|
|
767
|
+
agentControlToolEnabled: true, agentWebToolEnabled: true, browserProvider: 'builtin', agentMemoryToolEnabled: true, agentNotifyToolEnabled: true, isolatedSpacesEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
|
|
768
768
|
autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionOnlyArchived: false, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
|
|
769
769
|
openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
|
|
770
770
|
tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
|
|
771
771
|
managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
|
|
772
772
|
managedRemoteTunnelSelectedPresetId: 'a', managedRemoteTunnelPresetTokens: { a: 'token' },
|
|
773
|
-
sidebarProjectDisplayMode: 'all', sidebarViewMode: 'timeline', sidebarProjectSortOrder: 'manual', sidebarShowRecentSection: true,
|
|
773
|
+
sidebarProjectDisplayMode: 'all', sidebarViewMode: 'timeline', sidebarProjectSortOrder: 'manual', sidebarWorktreeSortOrder: 'recent', sidebarShowRecentSection: true,
|
|
774
774
|
workStatusPanelEnabled: true, workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: true, workStatusSectionOrder: ['mcp', 'session'],
|
|
775
775
|
showReasoningTraces: true, streamingAutoFollowEnabled: true, collapsibleThinkingBlocks: true, showTextJustificationActivity: true,
|
|
776
776
|
chatRenderMode: 'live', activityRenderMode: 'summary', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true,
|
|
@@ -123,9 +123,15 @@
|
|
|
123
123
|
"browserProvider": {
|
|
124
124
|
"scope": "instance"
|
|
125
125
|
},
|
|
126
|
+
"agentNotifyToolEnabled": {
|
|
127
|
+
"scope": "instance"
|
|
128
|
+
},
|
|
126
129
|
"agentMemoryToolEnabled": {
|
|
127
130
|
"scope": "instance"
|
|
128
131
|
},
|
|
132
|
+
"isolatedSpacesEnabled": {
|
|
133
|
+
"scope": "instance"
|
|
134
|
+
},
|
|
129
135
|
"agentMemoryFeatureAvailable": {
|
|
130
136
|
"scope": "instance",
|
|
131
137
|
"computed": true
|
|
@@ -222,6 +228,9 @@
|
|
|
222
228
|
"sidebarProjectSortOrder": {
|
|
223
229
|
"scope": "profile"
|
|
224
230
|
},
|
|
231
|
+
"sidebarWorktreeSortOrder": {
|
|
232
|
+
"scope": "profile"
|
|
233
|
+
},
|
|
225
234
|
"sidebarShowRecentSection": {
|
|
226
235
|
"scope": "profile"
|
|
227
236
|
},
|
|
@@ -674,6 +683,14 @@
|
|
|
674
683
|
"scope": "device",
|
|
675
684
|
"local": true
|
|
676
685
|
},
|
|
686
|
+
"diffFileListMode": {
|
|
687
|
+
"scope": "device",
|
|
688
|
+
"local": true
|
|
689
|
+
},
|
|
690
|
+
"diffFileTreeWidth": {
|
|
691
|
+
"scope": "device",
|
|
692
|
+
"local": true
|
|
693
|
+
},
|
|
677
694
|
"linearIssueListStatus": {
|
|
678
695
|
"scope": "device",
|
|
679
696
|
"local": true
|
|
@@ -280,11 +280,16 @@ function readConfigLayer(filePath) {
|
|
|
280
280
|
function readConfigLayers(workingDirectory) {
|
|
281
281
|
const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
|
|
282
282
|
const userPath = getPrimaryUserConfigPath(userPaths);
|
|
283
|
+
// OpenCode loads every global config file in order, so an `opencode.jsonc`
|
|
284
|
+
// next to `opencode.json` overrides it. New entries still go to the primary
|
|
285
|
+
// file; entries found in the override are edited where they live.
|
|
286
|
+
const userOverridePath = userPaths.find((candidate) => candidate !== userPath && fs.existsSync(candidate)) ?? null;
|
|
283
287
|
const userLayer = readConfigLayer(userPath);
|
|
288
|
+
const userOverrideLayer = readConfigLayer(userOverridePath);
|
|
284
289
|
const projectLayer = readConfigLayer(projectPath);
|
|
285
290
|
const customLayer = readConfigLayer(customPath);
|
|
286
291
|
const mergedConfig = mergeConfigs(
|
|
287
|
-
mergeConfigs(userLayer.config, projectLayer.config),
|
|
292
|
+
mergeConfigs(mergeConfigs(userLayer.config, userOverrideLayer.config), projectLayer.config),
|
|
288
293
|
customLayer.config,
|
|
289
294
|
);
|
|
290
295
|
|
|
@@ -292,6 +297,9 @@ function readConfigLayers(workingDirectory) {
|
|
|
292
297
|
if (userLayer.error) {
|
|
293
298
|
layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message });
|
|
294
299
|
}
|
|
300
|
+
if (userOverrideLayer.error && userOverridePath) {
|
|
301
|
+
layerErrors.push({ path: userOverridePath, code: userOverrideLayer.error.code, message: userOverrideLayer.error.message });
|
|
302
|
+
}
|
|
295
303
|
if (projectLayer.error && projectPath) {
|
|
296
304
|
layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message });
|
|
297
305
|
}
|
|
@@ -301,10 +309,11 @@ function readConfigLayers(workingDirectory) {
|
|
|
301
309
|
|
|
302
310
|
return {
|
|
303
311
|
userConfig: userLayer.config,
|
|
312
|
+
userOverrideConfig: userOverrideLayer.config,
|
|
304
313
|
projectConfig: projectLayer.config,
|
|
305
314
|
customConfig: customLayer.config,
|
|
306
315
|
mergedConfig,
|
|
307
|
-
paths: { userPath, projectPath, customPath },
|
|
316
|
+
paths: { userPath, userOverridePath, projectPath, customPath },
|
|
308
317
|
layerErrors,
|
|
309
318
|
};
|
|
310
319
|
}
|
|
@@ -407,6 +416,12 @@ function getJsonEntrySource(layers, sectionKind, entryName) {
|
|
|
407
416
|
if (project) return project;
|
|
408
417
|
}
|
|
409
418
|
|
|
419
|
+
if (paths.userOverridePath) {
|
|
420
|
+
throwIfLayerError(layers, paths.userOverridePath);
|
|
421
|
+
const userOverride = found(layers.userOverrideConfig, paths.userOverridePath);
|
|
422
|
+
if (userOverride) return userOverride;
|
|
423
|
+
}
|
|
424
|
+
|
|
410
425
|
throwIfLayerError(layers, paths.userPath);
|
|
411
426
|
const user = found(userConfig, paths.userPath);
|
|
412
427
|
if (user) return user;
|
|
@@ -509,7 +524,20 @@ function walkSkillMdFiles(rootDir) {
|
|
|
509
524
|
if (!rootDir || !fs.existsSync(rootDir)) return [];
|
|
510
525
|
|
|
511
526
|
const results = [];
|
|
527
|
+
// Real paths of the directories on the current walk path. Links (symlinks and
|
|
528
|
+
// Windows junctions) are followed at any depth; a link back to one of its own
|
|
529
|
+
// ancestors is skipped instead of recursing forever. Two links to the same
|
|
530
|
+
// target elsewhere in the tree are both walked, as the top level always did.
|
|
531
|
+
const ancestors = new Set();
|
|
512
532
|
const walk = (dir) => {
|
|
533
|
+
let realDir;
|
|
534
|
+
try {
|
|
535
|
+
realDir = fs.realpathSync(dir);
|
|
536
|
+
} catch {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (ancestors.has(realDir)) return;
|
|
540
|
+
|
|
513
541
|
let entries = [];
|
|
514
542
|
try {
|
|
515
543
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -517,16 +545,33 @@ function walkSkillMdFiles(rootDir) {
|
|
|
517
545
|
return;
|
|
518
546
|
}
|
|
519
547
|
|
|
548
|
+
ancestors.add(realDir);
|
|
549
|
+
|
|
520
550
|
for (const entry of entries) {
|
|
521
551
|
const fullPath = path.join(dir, entry.name);
|
|
522
|
-
|
|
552
|
+
// Junctions report as links, not directories. A link whose target cannot be
|
|
553
|
+
// stat'ed is skipped, the way an unreadable directory is, instead of failing
|
|
554
|
+
// the whole scan.
|
|
555
|
+
let isDirectoryEntry = entry.isDirectory();
|
|
556
|
+
let isFileEntry = entry.isFile();
|
|
557
|
+
if (entry.isSymbolicLink()) {
|
|
558
|
+
try {
|
|
559
|
+
const target = fs.statSync(fullPath);
|
|
560
|
+
isDirectoryEntry = target.isDirectory();
|
|
561
|
+
isFileEntry = target.isFile();
|
|
562
|
+
} catch {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (isDirectoryEntry) {
|
|
523
567
|
walk(fullPath);
|
|
524
568
|
continue;
|
|
525
569
|
}
|
|
526
|
-
if (
|
|
570
|
+
if (isFileEntry && entry.name === 'SKILL.md') {
|
|
527
571
|
results.push(fullPath);
|
|
528
572
|
}
|
|
529
573
|
}
|
|
574
|
+
ancestors.delete(realDir);
|
|
530
575
|
};
|
|
531
576
|
|
|
532
577
|
walk(rootDir);
|
|
@@ -3,7 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
|
|
6
|
-
import { parseMdFile, writeMdFile, readConfigFile, readConfigLayers, writeConfig } from './shared.js';
|
|
6
|
+
import { parseMdFile, writeMdFile, readConfigFile, readConfigLayers, writeConfig, walkSkillMdFiles } from './shared.js';
|
|
7
7
|
import { updateAgent } from './agents.js';
|
|
8
8
|
import { updateMcpConfig } from './mcp.js';
|
|
9
9
|
|
|
@@ -388,3 +388,80 @@ describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => {
|
|
|
388
388
|
}
|
|
389
389
|
});
|
|
390
390
|
});
|
|
391
|
+
|
|
392
|
+
describe('walkSkillMdFiles', () => {
|
|
393
|
+
beforeEach(() => {
|
|
394
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
395
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
afterEach(() => {
|
|
399
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const writeSkill = (relativeDir) => writeFixture(path.join(relativeDir, 'SKILL.md'), STANDARD_MD);
|
|
403
|
+
// A junction needs no elevation on Windows, which is what Skills Manager deploys;
|
|
404
|
+
// the type argument is ignored on POSIX.
|
|
405
|
+
const linkDirectory = (target, relativeLink) =>
|
|
406
|
+
fs.symlinkSync(target, path.join(FIXTURE_DIR, relativeLink), process.platform === 'win32' ? 'junction' : 'dir');
|
|
407
|
+
const foundRelative = () =>
|
|
408
|
+
walkSkillMdFiles(FIXTURE_DIR)
|
|
409
|
+
.map((found) => path.relative(FIXTURE_DIR, found).split(path.sep).join('/'))
|
|
410
|
+
.sort();
|
|
411
|
+
|
|
412
|
+
it('walks a skill deployed as a link inside the scanned root', () => {
|
|
413
|
+
writeSkill('real-skill');
|
|
414
|
+
fs.mkdirSync(path.join(FIXTURE_DIR, 'collection'), { recursive: true });
|
|
415
|
+
writeSkill(path.join('collection', 'nested-skill'));
|
|
416
|
+
linkDirectory(path.join(FIXTURE_DIR, 'real-skill'), 'linked-skill');
|
|
417
|
+
linkDirectory(path.join(FIXTURE_DIR, 'collection'), 'collection-link');
|
|
418
|
+
|
|
419
|
+
expect(foundRelative()).toEqual([
|
|
420
|
+
'collection-link/nested-skill/SKILL.md',
|
|
421
|
+
'collection/nested-skill/SKILL.md',
|
|
422
|
+
'linked-skill/SKILL.md',
|
|
423
|
+
'real-skill/SKILL.md',
|
|
424
|
+
]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('ends on a link that points back into the scanned tree', () => {
|
|
428
|
+
fs.mkdirSync(path.join(FIXTURE_DIR, 'loopdir'), { recursive: true });
|
|
429
|
+
writeSkill('loopdir');
|
|
430
|
+
linkDirectory(FIXTURE_DIR, path.join('loopdir', 'back'));
|
|
431
|
+
|
|
432
|
+
expect(foundRelative()).toEqual(['loopdir/SKILL.md']);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('skips a link whose target is gone and keeps the rest of the scan', () => {
|
|
436
|
+
writeSkill('real-skill');
|
|
437
|
+
fs.mkdirSync(path.join(FIXTURE_DIR, 'gone-skill'), { recursive: true });
|
|
438
|
+
linkDirectory(path.join(FIXTURE_DIR, 'gone-skill'), 'dangling-link');
|
|
439
|
+
fs.rmSync(path.join(FIXTURE_DIR, 'gone-skill'), { recursive: true, force: true });
|
|
440
|
+
|
|
441
|
+
expect(foundRelative()).toEqual(['real-skill/SKILL.md']);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('follows a link nested below the top level of the scanned root', () => {
|
|
445
|
+
const outside = `${FIXTURE_DIR}-outside`;
|
|
446
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
447
|
+
fs.mkdirSync(path.join(outside, 'deep-skill'), { recursive: true });
|
|
448
|
+
fs.writeFileSync(path.join(outside, 'deep-skill', 'SKILL.md'), STANDARD_MD);
|
|
449
|
+
fs.mkdirSync(path.join(FIXTURE_DIR, 'group'), { recursive: true });
|
|
450
|
+
try {
|
|
451
|
+
linkDirectory(path.join(outside, 'deep-skill'), path.join('group', 'linked'));
|
|
452
|
+
expect(foundRelative()).toEqual(['group/linked/SKILL.md']);
|
|
453
|
+
} finally {
|
|
454
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('ends on a nested link loop between two directories', () => {
|
|
459
|
+
fs.mkdirSync(path.join(FIXTURE_DIR, 'a'), { recursive: true });
|
|
460
|
+
fs.mkdirSync(path.join(FIXTURE_DIR, 'b'), { recursive: true });
|
|
461
|
+
writeSkill('a');
|
|
462
|
+
linkDirectory(path.join(FIXTURE_DIR, 'b'), path.join('a', 'to-b'));
|
|
463
|
+
linkDirectory(path.join(FIXTURE_DIR, 'a'), path.join('b', 'to-a'));
|
|
464
|
+
|
|
465
|
+
expect(foundRelative()).toEqual(['a/SKILL.md', 'b/to-a/SKILL.md']);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -40,6 +40,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
40
40
|
getDictationRuntime,
|
|
41
41
|
getRelayService,
|
|
42
42
|
getRelayReconcileTimer,
|
|
43
|
+
getSpacesHost = () => null,
|
|
43
44
|
} = dependencies;
|
|
44
45
|
|
|
45
46
|
let shutdownPromise = null;
|
|
@@ -78,6 +79,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
78
79
|
() => clearInterval(getRelayReconcileTimer()),
|
|
79
80
|
() => getGuestSurfaceRuntime()?.stop(),
|
|
80
81
|
() => getRealtimeProxyRuntime()?.stop(),
|
|
82
|
+
// The isolated-spaces host, when the switch is on: its connections into spaces end here.
|
|
83
|
+
() => getSpacesHost()?.close(),
|
|
81
84
|
() => getRelayService()?.stop(),
|
|
82
85
|
() => getDictationRuntime()?.stop(),
|
|
83
86
|
() => openCodeWatcherRuntime.stop(),
|