@openchambery/web 1.17.1 → 1.18.1
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/bin/lib/DOCUMENTATION.md +1 -0
- package/dist/assets/{AgentAvatar-C6Hxu5_o.js → AgentAvatar-B2kZjgaf.js} +1 -1
- package/dist/assets/{AssistantView-CbnxMZU0.js → AssistantView-f5eK9QED.js} +1 -1
- package/dist/assets/{ChatContainer-DEDGosbz.js → ChatContainer-BvJoxcjJ.js} +40 -40
- package/dist/assets/{DiagramView-Byxhaduz.js → DiagramView-Vu2JnmC-.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CvNA_aGD.js → FadeInOnReveal-aeWlaqVB.js} +1 -1
- package/dist/assets/{Icon-BPJ_quEe.js → Icon-AXqa-fYB.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-Cv8V-Q5l.js → MarkdownRendererImpl-CSxVZ_eA.js} +2 -2
- package/dist/assets/{MobileDetailNavigation-lVtCe-5P.js → MobileDetailNavigation-3NIRdxpt.js} +1 -1
- package/dist/assets/MobileShareBridge-B7jqfJ-S.js +4 -0
- package/dist/assets/MobileSurface-BL3hired.js +2 -0
- package/dist/assets/{MultiRunWindow-1kkKTh4h.js → MultiRunWindow-DkYTqcPq.js} +1 -1
- package/dist/assets/{OnboardingScreen-CdvaEBtt.js → OnboardingScreen-CCfdEtW6.js} +1 -1
- package/dist/assets/{OverlayScrollbar-Dqk1zGJB.js → OverlayScrollbar-DeL1aO59.js} +1 -1
- package/dist/assets/SettingsView-CyrIDyDl.js +25 -0
- package/dist/assets/{SettingsWindow-DMQMRgc3.js → SettingsWindow--nLXOo-L.js} +1 -1
- package/dist/assets/{TerminalView-jYq0JQPc.js → TerminalView-B80SmYHn.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DJefd2N6.js → ToolOutputDialog-CtbnU35x.js} +1 -1
- package/dist/assets/{appThemeRegistry-BXeIggYO.js → appThemeRegistry-D2ZUcQmr.js} +2 -2
- package/dist/assets/{button-BmNJBT13.js → button-CDvMVaiO.js} +1 -1
- package/dist/assets/{clipboard-CXWbHCAk.js → clipboard-Jdqe05db.js} +1 -1
- package/dist/assets/{collapsible-68IqWBM1.js → collapsible-C2aamDvD.js} +1 -1
- package/dist/assets/{context-menu-BbRpMBQh.js → context-menu-D16ocNrk.js} +1 -1
- package/dist/assets/desktopHosts-CBoBpJ1Z.js +1 -0
- package/dist/assets/desktopRelayRestore-l2R9muux.js +1 -0
- package/dist/assets/{diagram-YN0GZ1Q0.js → diagram-CEon0BQs.js} +1 -1
- package/dist/assets/{dialog-BV1ENlLf.js → dialog-BzZ3cKxG.js} +1 -1
- package/dist/assets/es-C25PRSjo.js +15 -0
- package/dist/assets/{fileQueries-i4oziMnC.js → fileQueries-CE_DaZWQ.js} +1 -1
- package/dist/assets/fr-Cv8ZWQdW.js +13 -0
- package/dist/assets/{gitApi-Bs_2iBAm.js → gitApi-B1CIqazO.js} +3 -3
- package/dist/assets/gitApiHttp-D8j1qRH4.js +2 -0
- package/dist/assets/{input-BLVhPvhN.js → input-k9DGGyZ6.js} +1 -1
- package/dist/assets/insertionBoundaries-B9cwRNac.js +1 -0
- package/dist/assets/{ja-Dcb0VtDE.js → ja-Ue9m9eSe.js} +3 -3
- package/dist/assets/{ko-CX71xNDX.js → ko-DWBUCCV6.js} +3 -3
- package/dist/assets/{main-CT0DuuLm.js → main-BSVnX01h.js} +2 -2
- package/dist/assets/main-DzZumJ48.js +172 -0
- package/dist/assets/{markdown-worker-Cq3Twp1A.js → markdown-worker-DkeWiER6.js} +1 -1
- package/dist/assets/{markdownTheme-CCnvI6L0.js → markdownTheme-DNT93i5L.js} +1 -1
- package/dist/assets/miniChat-CrEtOV4u.js +2 -0
- package/dist/assets/mobile-DMjO-f5u.js +2 -0
- package/dist/assets/multirun-G-ZwKVUq.js +4 -0
- package/dist/assets/persistence-DPNMIbF5.js +3 -0
- package/dist/assets/{pl-mR_3Sqr8.js → pl-DaBaPCyE.js} +3 -3
- package/dist/assets/projectMeta-owiDYjF9.js +3 -0
- package/dist/assets/pt-BR-C2cJZ43o.js +15 -0
- package/dist/assets/radio-BtilB34p.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-D1HWiWMn.js +2 -0
- package/dist/assets/renderMobileApp-Cqxwb3Ub.js +23 -0
- package/dist/assets/runtimeConfig-BtWi74Pm.js +1 -0
- package/dist/assets/runtimeEndpointReset-C0oDuy2R.js +145 -0
- package/dist/assets/{sortable-tabs-strip-oKJ700WG.js → sortable-tabs-strip-BpvOof2G.js} +1 -1
- package/dist/assets/src-oHrYIqHF.css +2 -0
- package/dist/assets/{store-oojlrF4d.js → store-SqV3zfE8.js} +4 -4
- package/dist/assets/{terminalApi-BYTA045F.js → terminalApi-tB3qLcfa.js} +1 -1
- package/dist/assets/{tooltip-BYGBBTc2.js → tooltip-DMNR5rN0.js} +1 -1
- package/dist/assets/uk-DJz_P8OZ.js +15 -0
- package/dist/assets/{url-CGHw6D-E.js → url-7MqoLs9X.js} +1 -1
- package/dist/assets/useAppFontEffects-BBxqFIta.js +7 -0
- package/dist/assets/{useAssistantUIStore-ESTV1mEY.js → useAssistantUIStore-Bq3cPj2x.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-Cq5a2gcJ.js → useDesktopWindowControlsLayout-D8fjPyqI.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DG6uPzBG.js → useEffectiveDirectory-C5OxWqUH.js} +1 -1
- package/dist/assets/{useFontPreferences-Bu4ME5hk.js → useFontPreferences-CIurCeNv.js} +1 -1
- package/dist/assets/useMobileNavigationStore-U3b_hG4m.js +475 -0
- package/dist/assets/useSessionAutoCleanup-CDYDm76L.js +1 -0
- package/dist/assets/useUIStore-5agS8bsW.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-ADgu9578.js → useWorkerHighlightedLines-CJozblYF.js} +1 -1
- package/dist/assets/{utils-DyUyiLje.js → utils-Co19EJEL.js} +1 -1
- package/dist/assets/zh-CN-DLAKDOM9.js +15 -0
- package/dist/assets/zh-TW-s3Jjx1_n.js +15 -0
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.d.ts +4 -0
- package/server/index.js +29 -15
- package/server/lib/client-auth/pairing.js +7 -1
- package/server/lib/client-auth/pairing.test.js +45 -0
- package/server/lib/desktop-hosts/routes.js +157 -0
- package/server/lib/desktop-hosts/routes.test.js +227 -0
- package/server/lib/message-queue/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/routes.js +1 -1
- package/server/lib/message-queue/routes.test.js +1 -0
- package/server/lib/message-queue/service.js +12 -1
- package/server/lib/message-queue/service.test.js +30 -0
- package/server/lib/opencode/DOCUMENTATION.md +4 -4
- package/server/lib/opencode/bootstrap-runtime.js +7 -0
- package/server/lib/opencode/cli-entry-runtime.js +6 -0
- package/server/lib/opencode/cli-entry-runtime.test.js +43 -0
- package/server/lib/opencode/core-routes.js +4 -1
- package/server/lib/opencode/core-routes.test.js +29 -0
- package/server/lib/opencode/openchamber-routes.js +12 -0
- package/server/lib/opencode/settings-runtime.js +1 -1
- package/server/lib/package-manager.js +3 -1
- package/server/lib/relay/DOCUMENTATION.md +44 -1
- package/server/lib/relay/host-client.js +4 -1
- package/server/lib/relay/service.js +16 -8
- package/server/lib/relay/service.test.js +21 -6
- package/server/lib/relay/tunnel-host.js +57 -4
- package/server/lib/relay/tunnel-host.test.js +250 -2
- package/dist/assets/MobileShareBridge-DAlvgjo1.js +0 -4
- package/dist/assets/MobileSurface-Bde8yLWo.js +0 -2
- package/dist/assets/SettingsView-D0dhTIur.js +0 -25
- package/dist/assets/desktopHosts-BpjUSDz4.js +0 -1
- package/dist/assets/desktopRelayRestore-BzUEk1i7.js +0 -1
- package/dist/assets/es-EXZUX1sB.js +0 -15
- package/dist/assets/fr-jWNPO91J.js +0 -13
- package/dist/assets/gitApiHttp-C1DaEMKR.js +0 -2
- package/dist/assets/insertionBoundaries-BAFtAotq.js +0 -1
- package/dist/assets/main-G8tKFcTV.js +0 -172
- package/dist/assets/miniChat-BP3OHqNF.js +0 -2
- package/dist/assets/mobile-BtwuDs-C.js +0 -2
- package/dist/assets/multirun-B-L3JH60.js +0 -4
- package/dist/assets/persistence-C_KCiMcY.js +0 -3
- package/dist/assets/projectMeta-Cp74cItv.js +0 -3
- package/dist/assets/pt-BR-D1RpVOvC.js +0 -15
- package/dist/assets/radio-Bs4mdPv5.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-dgirEOtR.js +0 -2
- package/dist/assets/renderMobileApp-DK9qzy0P.js +0 -23
- package/dist/assets/runtimeConfig-JSaYFA5x.js +0 -1
- package/dist/assets/runtimeEndpointReset-C0eCWIE6.js +0 -145
- package/dist/assets/src-8zziWB78.css +0 -2
- package/dist/assets/uk-UvnVqWv-.js +0 -15
- package/dist/assets/useAppFontEffects-BhLcUfRB.js +0 -7
- package/dist/assets/useMobileNavigationStore-1xHjnAsg.js +0 -475
- package/dist/assets/useSessionAutoCleanup-BFletAeV.js +0 -1
- package/dist/assets/useUIStore-D6BTB4va.js +0 -1
- package/dist/assets/zh-CN-9v-RC966.js +0 -15
- package/dist/assets/zh-TW-BZ_wZdC-.js +0 -15
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
|
|
5
|
+
import { registerDesktopHostRoutes } from './routes.js';
|
|
6
|
+
|
|
7
|
+
const baseSettings = {
|
|
8
|
+
desktopSshInstances: [{ id: 'ssh-1' }, { id: 'ssh-2' }],
|
|
9
|
+
desktopHosts: [
|
|
10
|
+
{ id: 'ssh-1', clientToken: 'ssh-token-1' },
|
|
11
|
+
{ id: 'ssh-2', clientToken: 'ssh-token-2' },
|
|
12
|
+
{ id: 'lan-other', clientToken: 'other-token' },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const createApp = ({
|
|
17
|
+
settings = baseSettings,
|
|
18
|
+
getSshRoutingTable = () => [],
|
|
19
|
+
getPairingSession,
|
|
20
|
+
mintSshHostToken,
|
|
21
|
+
logger = { warn: vi.fn(), error: vi.fn() },
|
|
22
|
+
} = {}) => {
|
|
23
|
+
const app = express();
|
|
24
|
+
registerDesktopHostRoutes(app, {
|
|
25
|
+
express,
|
|
26
|
+
readSettingsFromDiskMigrated: async () => settings,
|
|
27
|
+
getSshRoutingTable,
|
|
28
|
+
getPairingSession,
|
|
29
|
+
mintSshHostToken,
|
|
30
|
+
logger,
|
|
31
|
+
});
|
|
32
|
+
return { app, logger };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('desktop host routes', () => {
|
|
36
|
+
it('lists SSH hosts with reachable from the routing table and never returns clientToken', async () => {
|
|
37
|
+
const { app } = createApp({
|
|
38
|
+
settings: {
|
|
39
|
+
desktopSshInstances: [
|
|
40
|
+
{ id: 'ssh-1', nickname: 'Remote A', localForward: { preferredLocalPort: 41001 } },
|
|
41
|
+
{ id: 'ssh-2', nickname: 'Remote B', localForward: { preferredLocalPort: 41002 } },
|
|
42
|
+
],
|
|
43
|
+
desktopHosts: [
|
|
44
|
+
{ id: 'ssh-1', label: 'Remote A', clientToken: 'secret-a' },
|
|
45
|
+
{ id: 'ssh-2', label: 'Remote B', clientToken: 'secret-b' },
|
|
46
|
+
{ id: 'lan-other', label: 'Other', clientToken: 'secret-other' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const res = await request(app).get('/api/openchamber/desktop-hosts');
|
|
53
|
+
expect(res.status).toBe(200);
|
|
54
|
+
expect(res.body).toEqual({
|
|
55
|
+
hosts: [
|
|
56
|
+
{ id: 'ssh-1', label: 'Remote A', localPort: 41234, reachable: true },
|
|
57
|
+
{ id: 'ssh-2', label: 'Remote B', localPort: 41002, reachable: false },
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
expect(JSON.stringify(res.body)).not.toContain('secret');
|
|
61
|
+
expect(JSON.stringify(res.body)).not.toContain('clientToken');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('issues token with localPort/reachable when pairingId matches a redeemed session', async () => {
|
|
65
|
+
const getPairingSession = vi.fn(async (id) => {
|
|
66
|
+
if (id !== 'pair_ok') return null;
|
|
67
|
+
return { id: 'pair_ok', sshHostId: 'ssh-1', usedAt: '2026-01-01T00:00:00.000Z' };
|
|
68
|
+
});
|
|
69
|
+
const { app, logger } = createApp({
|
|
70
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
71
|
+
getPairingSession,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const ok = await request(app)
|
|
75
|
+
.post('/api/openchamber/ssh-host-token')
|
|
76
|
+
.send({ hostId: 'ssh-1', pairingId: 'pair_ok' });
|
|
77
|
+
expect(ok.status).toBe(200);
|
|
78
|
+
expect(ok.headers['cache-control']).toBe('no-store');
|
|
79
|
+
expect(ok.body).toEqual({
|
|
80
|
+
token: 'ssh-token-1',
|
|
81
|
+
localPort: 41234,
|
|
82
|
+
reachable: true,
|
|
83
|
+
});
|
|
84
|
+
expect(getPairingSession).toHaveBeenCalledWith('pair_ok');
|
|
85
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns 403 when pairingId sshHostId does not match hostId', async () => {
|
|
89
|
+
const { app } = createApp({
|
|
90
|
+
getPairingSession: async () => ({
|
|
91
|
+
id: 'pair_x',
|
|
92
|
+
sshHostId: 'ssh-2',
|
|
93
|
+
usedAt: '2026-01-01T00:00:00.000Z',
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const res = await request(app)
|
|
98
|
+
.post('/api/openchamber/ssh-host-token')
|
|
99
|
+
.send({ hostId: 'ssh-1', pairingId: 'pair_x' });
|
|
100
|
+
expect(res.status).toBe(403);
|
|
101
|
+
expect(res.body).toEqual({ error: 'pairing-mismatch' });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns 403 when pairing session is not redeemed', async () => {
|
|
105
|
+
const { app } = createApp({
|
|
106
|
+
getPairingSession: async () => ({
|
|
107
|
+
id: 'pair_pending',
|
|
108
|
+
sshHostId: 'ssh-1',
|
|
109
|
+
usedAt: null,
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const res = await request(app)
|
|
114
|
+
.post('/api/openchamber/ssh-host-token')
|
|
115
|
+
.send({ hostId: 'ssh-1', pairingId: 'pair_pending' });
|
|
116
|
+
expect(res.status).toBe(403);
|
|
117
|
+
expect(res.body).toEqual({ error: 'pairing-mismatch' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns 403 when pairingId is unknown', async () => {
|
|
121
|
+
const { app } = createApp({
|
|
122
|
+
getPairingSession: async () => null,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const res = await request(app)
|
|
126
|
+
.post('/api/openchamber/ssh-host-token')
|
|
127
|
+
.send({ hostId: 'ssh-1', pairingId: 'pair_missing' });
|
|
128
|
+
expect(res.status).toBe(403);
|
|
129
|
+
expect(res.body).toEqual({ error: 'pairing-mismatch' });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('allows legacy mint without pairingId and logs a deprecation warning', async () => {
|
|
133
|
+
const { app, logger } = createApp({
|
|
134
|
+
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const ok = await request(app)
|
|
138
|
+
.post('/api/openchamber/ssh-host-token')
|
|
139
|
+
.send({ hostId: 'ssh-1' });
|
|
140
|
+
expect(ok.status).toBe(200);
|
|
141
|
+
expect(ok.headers['cache-control']).toBe('no-store');
|
|
142
|
+
expect(ok.body).toEqual({
|
|
143
|
+
token: 'ssh-token-1',
|
|
144
|
+
localPort: 41234,
|
|
145
|
+
reachable: true,
|
|
146
|
+
});
|
|
147
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(String(logger.warn.mock.calls[0][0])).toMatch(/deprecated/i);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns reachable false and localPort null when routing table misses', async () => {
|
|
152
|
+
const { app } = createApp({
|
|
153
|
+
getSshRoutingTable: () => [],
|
|
154
|
+
getPairingSession: async () => ({
|
|
155
|
+
id: 'pair_ok',
|
|
156
|
+
sshHostId: 'ssh-1',
|
|
157
|
+
usedAt: '2026-01-01T00:00:00.000Z',
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const res = await request(app)
|
|
162
|
+
.post('/api/openchamber/ssh-host-token')
|
|
163
|
+
.send({ hostId: 'ssh-1', pairingId: 'pair_ok' });
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
expect(res.body).toEqual({
|
|
166
|
+
token: 'ssh-token-1',
|
|
167
|
+
localPort: null,
|
|
168
|
+
reachable: false,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('returns 404 for missing / non-SSH / tokenless hosts', async () => {
|
|
173
|
+
const { app } = createApp();
|
|
174
|
+
|
|
175
|
+
const missing = await request(app)
|
|
176
|
+
.post('/api/openchamber/ssh-host-token')
|
|
177
|
+
.send({ hostId: 'missing' });
|
|
178
|
+
expect(missing.status).toBe(404);
|
|
179
|
+
|
|
180
|
+
const nonSsh = await request(app)
|
|
181
|
+
.post('/api/openchamber/ssh-host-token')
|
|
182
|
+
.send({ hostId: 'lan-other' });
|
|
183
|
+
expect(nonSsh.status).toBe(404);
|
|
184
|
+
|
|
185
|
+
const { app: noTokenApp } = createApp({
|
|
186
|
+
settings: {
|
|
187
|
+
desktopSshInstances: [{ id: 'ssh-empty' }],
|
|
188
|
+
desktopHosts: [{ id: 'ssh-empty' }],
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const noToken = await request(noTokenApp)
|
|
192
|
+
.post('/api/openchamber/ssh-host-token')
|
|
193
|
+
.send({ hostId: 'ssh-empty' });
|
|
194
|
+
expect(noToken.status).toBe(404);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('mints a token on demand when the stored SSH host is tokenless', async () => {
|
|
198
|
+
const mintSshHostToken = vi.fn(async (hostId) => {
|
|
199
|
+
expect(hostId).toBe('ssh-empty');
|
|
200
|
+
return 'minted-on-demand';
|
|
201
|
+
});
|
|
202
|
+
const { app } = createApp({
|
|
203
|
+
settings: {
|
|
204
|
+
desktopSshInstances: [{ id: 'ssh-empty' }],
|
|
205
|
+
desktopHosts: [{ id: 'ssh-empty' }],
|
|
206
|
+
},
|
|
207
|
+
getSshRoutingTable: () => [{ id: 'ssh-empty', localPort: 41234 }],
|
|
208
|
+
getPairingSession: async () => ({
|
|
209
|
+
id: 'pair_ok',
|
|
210
|
+
sshHostId: 'ssh-empty',
|
|
211
|
+
usedAt: '2026-01-01T00:00:00.000Z',
|
|
212
|
+
}),
|
|
213
|
+
mintSshHostToken,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const res = await request(app)
|
|
217
|
+
.post('/api/openchamber/ssh-host-token')
|
|
218
|
+
.send({ hostId: 'ssh-empty', pairingId: 'pair_ok' });
|
|
219
|
+
expect(res.status).toBe(200);
|
|
220
|
+
expect(res.body).toEqual({
|
|
221
|
+
token: 'minted-on-demand',
|
|
222
|
+
localPort: 41234,
|
|
223
|
+
reachable: true,
|
|
224
|
+
});
|
|
225
|
+
expect(mintSshHostToken).toHaveBeenCalledWith('ssh-empty');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -20,7 +20,7 @@ Import staging accepts canonical payloads with scope/item ordinals, payload hash
|
|
|
20
20
|
|
|
21
21
|
There is no long-lived "dormant module" state in production: cutover is explicit `shadow` → `active` (with optional `paused`). Documentation that previously described the production v3 queue as authoritative while this module stayed dormant is obsolete once authority is `active`.
|
|
22
22
|
|
|
23
|
-
Scopes key records by the server-derived runtime key, normalized directory, and session ID. Stateless Assistant clients use the stable synthetic session ID `assistant:<assistantID>` for this existing scope contract; execution still uses a fresh captured-workspace OpenCode Session per item. Client transport identity stays outside durable keys. Each scope exposes a stable `scopeID`, revision, and worktree lifecycle state. Edit reservations fence ordinary edit, removal, and manual-send mutations for their exact queue item only; they never fence reorder or unrelated rows in the scope. Reservation renewal binds queue item ID, token, and current authority generation; it accepts a bounded TTL until its durable expiry timestamp, commits only the expiry field in an immediate transaction, and leaves scope and global revisions unchanged. Reserved removal presents its token and authority generation in the same SQLite delete CAS. Releasing a reservation wakes the worker.
|
|
23
|
+
Scopes key records by the server-derived runtime key, normalized directory, and session ID. Stateless Assistant clients use the stable synthetic session ID `assistant:<assistantID>` for this existing scope contract; execution still uses a fresh captured-workspace OpenCode Session per item. Client transport identity stays outside durable keys. Each scope exposes a stable `scopeID`, revision, and worktree lifecycle state. A runtime holds at most 128 scopes; creating a new scope at the cap first evicts the oldest empty scopes (LRU by `updated_at`, scope ID tiebreak — safe because `queue_item` is the only foreign key into `queue_scope`), and admission fails with `scope_limit` (HTTP 409) only when every scope still holds items. Edit reservations fence ordinary edit, removal, and manual-send mutations for their exact queue item only; they never fence reorder or unrelated rows in the scope. Reservation renewal binds queue item ID, token, and current authority generation; it accepts a bounded TTL until its durable expiry timestamp, commits only the expiry field in an immediate transaction, and leaves scope and global revisions unchanged. Reserved removal presents its token and authority generation in the same SQLite delete CAS. Releasing a reservation wakes the worker.
|
|
24
24
|
|
|
25
25
|
## Per-item sendConfig
|
|
26
26
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const prefix = '/api/openchamber/message-queue';
|
|
2
|
-
const statusFor = (error) => ({ validation_error: 400, not_found: 404, revision_conflict: 409, row_version_conflict: 409, idempotency_conflict: 409, generation_conflict: 409, authority_conflict: 409, scope_locked: 409, reserved: 409, reservation_expired: 409, reservation_generation_conflict: 409, reservation_token_mismatch: 409, attachment_unavailable: 404, attachment_total_limit: 413, admission_payload_limit: 413, attachment_store_not_found: 404, attachment_store_max_bytes: 413, attachment_store_size_mismatch: 400, attachment_store_hash_mismatch: 400, attachment_store_aborted: 499 })[error?.code] ?? 500;
|
|
2
|
+
const statusFor = (error) => ({ validation_error: 400, not_found: 404, revision_conflict: 409, row_version_conflict: 409, idempotency_conflict: 409, generation_conflict: 409, authority_conflict: 409, scope_locked: 409, scope_limit: 409, reserved: 409, reservation_expired: 409, reservation_generation_conflict: 409, reservation_token_mismatch: 409, attachment_unavailable: 404, attachment_total_limit: 413, admission_payload_limit: 413, attachment_store_not_found: 404, attachment_store_max_bytes: 413, attachment_store_size_mismatch: 400, attachment_store_hash_mismatch: 400, attachment_store_aborted: 499 })[error?.code] ?? 500;
|
|
3
3
|
const publicCode = (error) => statusFor(error) === 500 ? 'internal_error' : error.code;
|
|
4
4
|
const unsupported = (res) => res.status(501).json({ code: 'unavailable' });
|
|
5
5
|
const send = (res, action) => { try { res.json(action()); } catch (error) { const status = statusFor(error); if (status === 500) console.error('[message-queue] route failed', error); res.status(status).json({ code: publicCode(error) }); } };
|
|
@@ -20,6 +20,7 @@ describe('message queue routes', () => {
|
|
|
20
20
|
});
|
|
21
21
|
it('maps stable errors to code-only responses', () => { const { app, route } = registry(); registerMessageQueueRoutes(app, { messageQueueService: { admit: () => { const error = new Error('private detail'); error.code = 'attachment_total_limit'; throw error; } } }); const res = response(); route('POST', `${prefix}/items`)({ body: {} }, res); expect(res).toMatchObject({ statusCode: 413, body: { code: 'attachment_total_limit' } }); });
|
|
22
22
|
it('maps admission payload limits to 413', () => { const { app, route } = registry(); registerMessageQueueRoutes(app, { messageQueueService: { admit: () => { const error = new Error('private detail'); error.code = 'admission_payload_limit'; throw error; } } }); const res = response(); route('POST', `${prefix}/items`)({ body: {} }, res); expect(res).toMatchObject({ statusCode: 413, body: { code: 'admission_payload_limit' } }); });
|
|
23
|
+
it('maps scope capacity exhaustion to 409 scope_limit', () => { const { app, route } = registry(); registerMessageQueueRoutes(app, { messageQueueService: { admit: () => { const error = new Error('private detail'); error.code = 'scope_limit'; throw error; } } }); const res = response(); route('POST', `${prefix}/items`)({ body: {} }, res); expect(res).toMatchObject({ statusCode: 409, body: { code: 'scope_limit' } }); });
|
|
23
24
|
it('wakes the runtime after a successful admit', () => {
|
|
24
25
|
const { app, route } = registry();
|
|
25
26
|
const admit = vi.fn(() => ({ revision: 1, queueItemID: 'item' }));
|
|
@@ -371,11 +371,22 @@ export const createMessageQueueService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
371
371
|
if (excess) db.prepare('DELETE FROM operation_receipt WHERE rowid IN (SELECT rowid FROM operation_receipt WHERE runtime_key = ? ORDER BY created_at, rowid LIMIT ?)').run(key, excess);
|
|
372
372
|
db.prepare('INSERT INTO operation_receipt(runtime_key,request_id,operation_type,payload_hash,response_json,committed_revision,created_at) VALUES (?,?,?,?,?,?,?)').run(key, requestID, operationType, payloadHash, JSON.stringify(response), revision, now());
|
|
373
373
|
};
|
|
374
|
+
// Empty scopes carry no durable queue rows (queue_item is the sole FK into
|
|
375
|
+
// queue_scope), so LRU eviction cannot strand items, attempts, receipts, or
|
|
376
|
+
// completions; worktree lifecycle state lives in its own table and scopes
|
|
377
|
+
// re-create from it on demand.
|
|
378
|
+
const evictEmptyScopes = (key, slots) => db.prepare(`DELETE FROM queue_scope WHERE runtime_key = ? AND scope_id IN (
|
|
379
|
+
SELECT s.scope_id FROM queue_scope s WHERE s.runtime_key = ? AND NOT EXISTS (SELECT 1 FROM queue_item i WHERE i.scope_id = s.scope_id)
|
|
380
|
+
ORDER BY s.updated_at, s.scope_id LIMIT ?
|
|
381
|
+
)`).run(key, key, slots);
|
|
374
382
|
const ensureScope = (scope, key) => {
|
|
375
383
|
const directory = normalizeDirectory(scope.directory); const scopeID = scopeIDFor(key, directory, scope.sessionID);
|
|
376
384
|
const state = lifecycle(key, directory)?.state ?? 'active';
|
|
377
385
|
db.prepare('INSERT OR IGNORE INTO worktree_lifecycle(runtime_key,directory,state,deletion_token,updated_at) VALUES (?,?,?,NULL,?)').run(key, directory, state, now());
|
|
378
|
-
if (!scopeRow(scopeID)
|
|
386
|
+
if (!scopeRow(scopeID)) {
|
|
387
|
+
const count = db.prepare('SELECT COUNT(*) AS count FROM queue_scope WHERE runtime_key = ?').get(key).count;
|
|
388
|
+
if (count >= MAX_SCOPES) { evictEmptyScopes(key, count - MAX_SCOPES + 1); if (Number(db.prepare('SELECT COUNT(*) AS count FROM queue_scope WHERE runtime_key = ?').get(key).count) >= MAX_SCOPES) fail('scope_limit'); }
|
|
389
|
+
}
|
|
379
390
|
db.prepare('INSERT OR IGNORE INTO queue_scope(scope_id,runtime_key,directory,session_id,revision,worktree_state,created_at,updated_at) VALUES (?,?,?,?,0,?,?,?)').run(scopeID, key, directory, scope.sessionID, state, now(), now());
|
|
380
391
|
return scopeRow(scopeID);
|
|
381
392
|
};
|
|
@@ -969,4 +969,34 @@ describe('message queue service', () => {
|
|
|
969
969
|
const overflow = stage('late-overflow', [addition('three')]); expect(() => service.commitLateImport({ requestID: 'late-overflow-commit', importID: overflow.created.importID, expectedGeneration: 1, manifestHash: overflow.sealed.manifestHash, protocol: 4 })).toThrow(expect.objectContaining({ code: 'validation_error' }));
|
|
970
970
|
service.close();
|
|
971
971
|
});
|
|
972
|
+
|
|
973
|
+
it('evicts oldest empty scopes when admission reaches the scope cap', () => {
|
|
974
|
+
const service = createService(dbPath());
|
|
975
|
+
const admissionFor = (suffix) => ({ requestID: `request-${suffix}`, scope: { directory: '/repo', sessionID: `session-${suffix}` }, item: { queueItemID: `item-${suffix}`, operationID: `operation-${suffix}`, messageID: `message-${suffix}`, content: 'hello', attachments: [], attachmentIssues: [], createdAt: 100 } });
|
|
976
|
+
let retained; const emptied = [];
|
|
977
|
+
for (let index = 0; index < 128; index += 1) {
|
|
978
|
+
const result = service.admit(admissionFor(index));
|
|
979
|
+
if (index === 0) { retained = result; continue; }
|
|
980
|
+
service.remove({ requestID: `remove-${index}`, queueItemID: result.queueItemID, expectedRevision: result.revision, expectedRowVersion: 1 });
|
|
981
|
+
emptied.push(result.scopeID);
|
|
982
|
+
}
|
|
983
|
+
expect(service.snapshot().scopes).toHaveLength(128);
|
|
984
|
+
const fresh = service.admit(admissionFor('fresh'));
|
|
985
|
+
expect(service.getScope(fresh.scopeID).items).toHaveLength(1);
|
|
986
|
+
expect(service.snapshot().scopes).toHaveLength(128);
|
|
987
|
+
const remaining = new Set(service.snapshot().scopes.map((scope) => scope.scopeID));
|
|
988
|
+
expect(remaining.has(fresh.scopeID)).toBe(true);
|
|
989
|
+
expect(remaining.has(retained.scopeID)).toBe(true);
|
|
990
|
+
expect(emptied.filter((scopeID) => remaining.has(scopeID))).toHaveLength(emptied.length - 1);
|
|
991
|
+
expect(service.getScope(retained.scopeID).items[0]).toMatchObject({ queueItemID: retained.queueItemID });
|
|
992
|
+
service.close();
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it('fails admission with scope_limit when every scope at the cap still holds items', () => {
|
|
996
|
+
const service = createService(dbPath());
|
|
997
|
+
const admissionFor = (suffix) => ({ requestID: `request-${suffix}`, scope: { directory: '/repo', sessionID: `session-${suffix}` }, item: { queueItemID: `item-${suffix}`, operationID: `operation-${suffix}`, messageID: `message-${suffix}`, content: 'hello', attachments: [], attachmentIssues: [], createdAt: 100 } });
|
|
998
|
+
for (let index = 0; index < 128; index += 1) service.admit(admissionFor(index));
|
|
999
|
+
expect(() => service.admit(admissionFor('overflow'))).toThrow(expect.objectContaining({ code: 'scope_limit' }));
|
|
1000
|
+
service.close();
|
|
1001
|
+
});
|
|
972
1002
|
});
|
|
@@ -8,7 +8,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
8
8
|
- `packages/web/server/lib/opencode/auth.js`: provider authentication file operations.
|
|
9
9
|
- `packages/web/server/lib/opencode/auth-state-runtime.js`: managed OpenCode server auth password/header runtime.
|
|
10
10
|
- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
|
|
11
|
-
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
|
|
11
|
+
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, forces `OPENCHAMBER_RUNTIME=web` (so `dev:server` cannot inherit a leftover desktop env and host a relay), parses CLI options, and starts server bootstrap.
|
|
12
12
|
- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
|
|
13
13
|
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). Managed child env inherits the user shell environment unchanged; experimental flags such as `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` follow the user's own environment and are never injected by OpenChamber.
|
|
14
14
|
- `packages/web/server/lib/opencode/managed-capabilities-runtime.js`: managed-child scheduled-task resources, config injection, rotating bridge identity, and bridge authorization.
|
|
@@ -33,7 +33,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
33
33
|
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: active loopback-port wiring shared by pairing/LAN URLs and the private relay host.
|
|
34
34
|
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
|
|
35
35
|
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
|
|
36
|
-
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update, models metadata, session-index,
|
|
36
|
+
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update, models metadata, session-index, transcript-cache, and desktop SSH host-token route registration. Tokenless ready SSH hosts may mint a client token on demand via Electron `mintSshHostToken`, which authenticates to the forwarded remote with the in-memory tunnel UI password.
|
|
37
37
|
- `packages/web/server/lib/transcript-cache/`: opt-in Electron/local SQLite transcript cache (`/api/openchamber/transcript-cache`); default path is null so remote Web servers do not persist conversation bodies. See `transcript-cache/DOCUMENTATION.md`.
|
|
38
38
|
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
|
|
39
39
|
- `packages/web/server/lib/opencode/project-icon-routes.js`: project icon upload/read/discovery route registration and icon storage orchestration.
|
|
@@ -273,7 +273,7 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
|
|
|
273
273
|
- `DELETE /api/passkeys/:id`
|
|
274
274
|
- `POST /api/auth/reset`
|
|
275
275
|
- `POST /api/client-auth/pairing/sessions`: accepts an optional `relayUrl` when Relay is included. An owner UI session or the local `desktop-local` shell client may set a custom endpoint (other client bearers receive HTTP 403). Canonical form is `ws://`/`wss://` scheme/host/path: userinfo is rejected (HTTP 400); query and fragment are stripped and are not part of endpoint identity. The Host persists the switch and reconnects its control connection before returning the pairing-v2 Relay candidate; an `OPENCHAMBER_RELAY_URL` override stays authoritative.
|
|
276
|
-
|
|
276
|
+
- `GET /api/client-auth/pairing/transports`: returns direct transport availability. `relayAvailable` is true only for the Electron desktop host; local `dev` / `web` / CLI servers report false and omit Relay URL fields so the create-device dialog never defaults to Anywhere or probes the relay.
|
|
277
277
|
- `GET /connect`
|
|
278
278
|
- `POST /api/system/probe-url`
|
|
279
279
|
- `app.use('/api', ...)` auth/tunnel guard
|
|
@@ -294,7 +294,7 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
|
|
|
294
294
|
- Legacy `--tunnel` shorthand normalization
|
|
295
295
|
|
|
296
296
|
## Public exports (cli-entry-runtime.js)
|
|
297
|
-
- `runCliEntryIfMain(dependencies)`: detects direct CLI execution and runs server startup with parsed CLI options.
|
|
297
|
+
- `runCliEntryIfMain(dependencies)`: detects direct CLI execution, forces `OPENCHAMBER_RUNTIME=web` so a leftover desktop env cannot host or probe a relay, and runs server startup with parsed CLI options.
|
|
298
298
|
|
|
299
299
|
## Public exports (server-utils-runtime.js)
|
|
300
300
|
- `createServerUtilsRuntime(dependencies)`: creates server utility runtime for OpenCode orchestration helpers.
|
|
@@ -61,6 +61,9 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
61
61
|
sessionIndexService,
|
|
62
62
|
sessionIndexSyncRuntime,
|
|
63
63
|
transcriptCacheService,
|
|
64
|
+
getSshRoutingTable,
|
|
65
|
+
mintSshHostToken,
|
|
66
|
+
getPairingSession,
|
|
64
67
|
} = options;
|
|
65
68
|
|
|
66
69
|
const uiAuthController = createUiAuth({
|
|
@@ -149,6 +152,10 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
149
152
|
sessionIndexService,
|
|
150
153
|
sessionIndexSyncRuntime,
|
|
151
154
|
transcriptCacheService,
|
|
155
|
+
getSshRoutingTable,
|
|
156
|
+
mintSshHostToken,
|
|
157
|
+
getPairingSession,
|
|
158
|
+
express,
|
|
152
159
|
});
|
|
153
160
|
|
|
154
161
|
return {
|
|
@@ -13,6 +13,12 @@ export const runCliEntryIfMain = (dependencies) => {
|
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Direct `node server/index.js` is the standalone web/dev server. Never inherit
|
|
17
|
+
// OPENCHAMBER_RUNTIME=desktop from the parent shell — that would open a relay
|
|
18
|
+
// host-control socket from `bun run dev` / `dev:server`. Electron sets desktop
|
|
19
|
+
// before importing this module and never takes this CLI path.
|
|
20
|
+
process.env.OPENCHAMBER_RUNTIME = 'web';
|
|
21
|
+
|
|
16
22
|
const cliOptions = parseServeCliOptions({
|
|
17
23
|
argv: process.argv.slice(2),
|
|
18
24
|
env: process.env,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { runCliEntryIfMain } from './cli-entry-runtime.js';
|
|
3
|
+
|
|
4
|
+
describe('cli-entry-runtime', () => {
|
|
5
|
+
const previousRuntime = process.env.OPENCHAMBER_RUNTIME;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (previousRuntime === undefined) delete process.env.OPENCHAMBER_RUNTIME;
|
|
9
|
+
else process.env.OPENCHAMBER_RUNTIME = previousRuntime;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('forces OPENCHAMBER_RUNTIME=web on the direct node server path even if the parent shell leaked desktop', () => {
|
|
13
|
+
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
|
14
|
+
const startServer = vi.fn(async () => {});
|
|
15
|
+
|
|
16
|
+
runCliEntryIfMain({
|
|
17
|
+
process: { argv: ['node', '/tmp/server/index.js', '--port', '3001'], env: process.env, exit: vi.fn() },
|
|
18
|
+
currentFilename: '/tmp/server/index.js',
|
|
19
|
+
parseServeCliOptions: () => ({ port: 3001, host: undefined, uiPassword: null, apiOnly: false }),
|
|
20
|
+
defaultPort: 3001,
|
|
21
|
+
setExitOnShutdown: vi.fn(),
|
|
22
|
+
startServer,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(process.env.OPENCHAMBER_RUNTIME).toBe('web');
|
|
26
|
+
expect(startServer).toHaveBeenCalledTimes(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('does not rewrite OPENCHAMBER_RUNTIME when imported as a library (Electron in-process)', () => {
|
|
30
|
+
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
|
31
|
+
|
|
32
|
+
runCliEntryIfMain({
|
|
33
|
+
process: { argv: ['electron', '/tmp/electron/main.mjs'], env: process.env, exit: vi.fn() },
|
|
34
|
+
currentFilename: '/tmp/server/index.js',
|
|
35
|
+
parseServeCliOptions: vi.fn(),
|
|
36
|
+
defaultPort: 3001,
|
|
37
|
+
setExitOnShutdown: vi.fn(),
|
|
38
|
+
startServer: vi.fn(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(process.env.OPENCHAMBER_RUNTIME).toBe('desktop');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -395,7 +395,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
395
395
|
// Returns { local, lan, relayAvailable } — the direct transport URLs the
|
|
396
396
|
// server can actually be reached on (LAN derived from the server bind, not
|
|
397
397
|
// the UI origin), for the create-device dialog.
|
|
398
|
-
getPairingTransports = () => ({ local: null, lan: null, relayAvailable:
|
|
398
|
+
getPairingTransports = () => ({ local: null, lan: null, relayAvailable: false }),
|
|
399
399
|
// Returns ALL direct LAN URLs the server is currently reachable on (client-
|
|
400
400
|
// reached address first, then interface scan) for the candidates-refresh
|
|
401
401
|
// endpoint. Empty when the server is loopback-only.
|
|
@@ -871,6 +871,9 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
871
871
|
allowedClientKinds: req.body?.allowedClientKinds,
|
|
872
872
|
createdByClientId: clientIdFromAuthContext(authContext),
|
|
873
873
|
usesRelay,
|
|
874
|
+
// Optional SSH instance id: binds mobile ssh-host-token mint to this
|
|
875
|
+
// pairing after redeem. Does not affect redeem itself.
|
|
876
|
+
sshHostId: req.body?.sshHostId,
|
|
874
877
|
});
|
|
875
878
|
void reconcileRelay();
|
|
876
879
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -774,9 +774,24 @@ describe('core-routes', () => {
|
|
|
774
774
|
allowedClientKinds: ['mobile'],
|
|
775
775
|
createdByClientId: null,
|
|
776
776
|
usesRelay: false,
|
|
777
|
+
sshHostId: undefined,
|
|
777
778
|
});
|
|
778
779
|
});
|
|
779
780
|
|
|
781
|
+
it('forwards optional sshHostId into createPairingSession', async () => {
|
|
782
|
+
const { app, dependencies } = createPairingRouteApp();
|
|
783
|
+
|
|
784
|
+
await request(app)
|
|
785
|
+
.post('/api/client-auth/pairing/sessions')
|
|
786
|
+
.set('Host', 'runtime.example')
|
|
787
|
+
.send({ label: 'SSH phone', allowedClientKinds: ['mobile'], sshHostId: 'ssh-1', includeRelay: true })
|
|
788
|
+
.expect(201);
|
|
789
|
+
|
|
790
|
+
expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith(
|
|
791
|
+
expect.objectContaining({ sshHostId: 'ssh-1' }),
|
|
792
|
+
);
|
|
793
|
+
});
|
|
794
|
+
|
|
780
795
|
it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => {
|
|
781
796
|
const { app } = createPairingRouteApp();
|
|
782
797
|
|
|
@@ -988,6 +1003,20 @@ describe('core-routes', () => {
|
|
|
988
1003
|
expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalled();
|
|
989
1004
|
});
|
|
990
1005
|
|
|
1006
|
+
it('defaults pairing transports to relay unavailable so web/dev never advertise Anywhere', async () => {
|
|
1007
|
+
const { app } = createPairingRouteApp();
|
|
1008
|
+
|
|
1009
|
+
const response = await request(app)
|
|
1010
|
+
.get('/api/client-auth/pairing/transports')
|
|
1011
|
+
.expect(200);
|
|
1012
|
+
|
|
1013
|
+
expect(response.body).toEqual({
|
|
1014
|
+
local: null,
|
|
1015
|
+
lan: null,
|
|
1016
|
+
relayAvailable: false,
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
|
|
991
1020
|
it('reports the effective Relay endpoint and environment lock to the pairing dialog', async () => {
|
|
992
1021
|
const { app } = createPairingRouteApp({
|
|
993
1022
|
getPairingTransports: vi.fn(async () => ({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registerSessionIndexRoutes } from '../session-index/routes.js';
|
|
2
2
|
import { registerTranscriptCacheRoutes } from '../transcript-cache/routes.js';
|
|
3
|
+
import { registerDesktopHostRoutes } from '../desktop-hosts/routes.js';
|
|
3
4
|
|
|
4
5
|
export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
5
6
|
const {
|
|
@@ -15,10 +16,21 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
15
16
|
sessionIndexService,
|
|
16
17
|
sessionIndexSyncRuntime,
|
|
17
18
|
transcriptCacheService,
|
|
19
|
+
getSshRoutingTable,
|
|
20
|
+
mintSshHostToken,
|
|
21
|
+
getPairingSession,
|
|
22
|
+
express,
|
|
18
23
|
} = dependencies;
|
|
19
24
|
|
|
20
25
|
registerSessionIndexRoutes(app, { sessionIndexService, sessionIndexSyncRuntime });
|
|
21
26
|
registerTranscriptCacheRoutes(app, { transcriptCacheService });
|
|
27
|
+
registerDesktopHostRoutes(app, {
|
|
28
|
+
readSettingsFromDiskMigrated,
|
|
29
|
+
getSshRoutingTable,
|
|
30
|
+
mintSshHostToken,
|
|
31
|
+
getPairingSession,
|
|
32
|
+
express,
|
|
33
|
+
});
|
|
22
34
|
|
|
23
35
|
app.get('/api/openchamber/update-check', async (req, res) => {
|
|
24
36
|
try {
|
|
@@ -695,7 +695,7 @@ export const createSettingsRuntime = (deps) => {
|
|
|
695
695
|
const next = { ...settings };
|
|
696
696
|
|
|
697
697
|
if (typeof settings.notifyOnSubtasks !== 'boolean') {
|
|
698
|
-
next.notifyOnSubtasks =
|
|
698
|
+
next.notifyOnSubtasks = false;
|
|
699
699
|
changed = true;
|
|
700
700
|
}
|
|
701
701
|
if (typeof settings.notifyOnCompletion !== 'boolean') {
|
|
@@ -669,7 +669,9 @@ export function getUpdateCommand(pm = detectPackageManager()) {
|
|
|
669
669
|
case 'bun':
|
|
670
670
|
return `${pmCommand} add -g ${PACKAGE_NAME}@latest`;
|
|
671
671
|
default:
|
|
672
|
-
|
|
672
|
+
// --force: npm refuses to overwrite an existing global bin (EEXIST) when
|
|
673
|
+
// a previous install left the bin file behind.
|
|
674
|
+
return `${pmCommand} install -g ${PACKAGE_NAME}@latest --force`;
|
|
673
675
|
}
|
|
674
676
|
}
|
|
675
677
|
|
|
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
|
|
19
19
|
## Entrypoints and structure
|
|
20
20
|
|
|
21
21
|
Host side (`packages/web/server/lib/relay/`):
|
|
22
|
-
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Host lifecycle is gated to Electron: non-desktop runtimes report `state: 'unavailable'`, refuse `/relay/enable
|
|
22
|
+
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. Host lifecycle is gated to Electron: non-desktop runtimes (`bun run dev`, `dev:web:hmr`, CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `OPENCHAMBER_RUNTIME=web` so a leftover desktop env cannot open the host-control socket. On desktop, started from `packages/web/server/index.js` only when demand/opt-in enables the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session or the local `desktop-local` shell client; `/relay/enable` follows its API auth gate.
|
|
23
23
|
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
|
24
24
|
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
|
|
25
25
|
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
|
@@ -104,6 +104,48 @@ Relay mode plugs into the existing client transport layer rather than a parallel
|
|
|
104
104
|
|
|
105
105
|
Catalog loaders (`loadProviders` / `loadAgents`) and assistant Query keys gate writes/caches on the **transport fingerprint**, not `runtimeKey`. On endpoint reset and same-device transport switch, `runtimeEndpointReset.ts` must set `useConfigStore.catalogTransportIdentity` to `getRuntimeTransportIdentity()`. Writing `detail.runtimeKey` there silently discards provider/agent catalog refreshes under Relay, which then hides capability-gated surfaces such as Assistants.
|
|
106
106
|
|
|
107
|
+
## SSH host routing
|
|
108
|
+
|
|
109
|
+
Mobile (and other relay clients) can reach a desktop's **SSH-forwarded remote OpenChamber instances** through the same private-relay tunnel that already reaches the desktop's local loopback origin. Routing is host-side only: the client selects a target local-forward port; the tunnel dispatcher dials that port on `127.0.0.1` when — and only when — it is present in the live SSH routing table.
|
|
110
|
+
|
|
111
|
+
### Header contract
|
|
112
|
+
|
|
113
|
+
- Header name: `x-openchamber-target-port`
|
|
114
|
+
- Value: decimal port number string (e.g. `"41234"`)
|
|
115
|
+
- HTTP: carried on the tunneled request headers
|
|
116
|
+
- WebSocket: optional `headers` field on the `WsOpen` payload (same header name/value)
|
|
117
|
+
- The dispatcher **strips** this header before forwarding to loopback (it is routing metadata, not an upstream header)
|
|
118
|
+
|
|
119
|
+
### Resolution rules (`resolveTargetPort` in `tunnel-host.js`)
|
|
120
|
+
|
|
121
|
+
| Condition | Result |
|
|
122
|
+
|---|---|
|
|
123
|
+
| Header absent | Dial `getLocalPort()` (desktop local origin) — unchanged default behavior |
|
|
124
|
+
| Header present and `localPort` is in `getSshRoutingTable()` | Dial that port |
|
|
125
|
+
| Header present but port missing/invalid/not in table | **Do not** fall back to the default port |
|
|
126
|
+
|
|
127
|
+
HTTP miss → synthetic **503** with body `{ error: 'ssh-host-unreachable', …, source: 'relay-tunnel-host' }`.
|
|
128
|
+
WS miss → stream abort with reason `ssh-host-unreachable`.
|
|
129
|
+
|
|
130
|
+
### Routing table (authoritative in memory)
|
|
131
|
+
|
|
132
|
+
- Electron injects `getSshRoutingTable: () => sshManager.getRoutingTable()` into `startWebUiServer`.
|
|
133
|
+
- Table entries are `{ id, localPort }` for SSH sessions whose status phase is **`ready`** and whose `localPort` is finite.
|
|
134
|
+
- Degraded / connecting / disconnected sessions are absent. Settings.json is **not** the allowlist for dialing — only the live table is.
|
|
135
|
+
- Non-desktop runtimes default to `() => []`.
|
|
136
|
+
|
|
137
|
+
### Companion HTTP APIs (authenticated `/api` gate)
|
|
138
|
+
|
|
139
|
+
- `GET /api/openchamber/desktop-hosts` → `{ hosts: [{ id, label, localPort, reachable }] }` for configured SSH instances. `reachable` is true iff the host id is in the routing table. **Never returns `clientToken`.**
|
|
140
|
+
- `POST /api/openchamber/ssh-host-token` body `{ hostId }` → `{ token }` from `settings.json` `desktopHosts[].clientToken` for SSH instance hosts only. Missing / non-SSH / no token → **404**. Response includes `Cache-Control: no-store`.
|
|
141
|
+
|
|
142
|
+
### Security invariants (do not regress)
|
|
143
|
+
|
|
144
|
+
- Path allowlists (`isAllowedHttpPath` / `ALLOWED_WS_PATHS`) are **not** relaxed for SSH targets.
|
|
145
|
+
- The dispatcher **never injects credentials**; the client still authenticates with its own bearer / `oc_url_token` against the target instance.
|
|
146
|
+
- Port legitimacy is solely the in-memory routing table, not client-supplied trust and not settings alone.
|
|
147
|
+
- List endpoints must not leak `clientToken`; token mint is a separate authenticated POST.
|
|
148
|
+
|
|
107
149
|
## Design invariants (do not regress)
|
|
108
150
|
|
|
109
151
|
- The relay never sees plaintext application traffic; it sees only routing metadata (routing id, connection identifiers, timestamps, coarse counts).
|
|
@@ -112,5 +154,6 @@ Catalog loaders (`loadProviders` / `loadAgents`) and assistant Query keys gate w
|
|
|
112
154
|
- The host dispatcher never injects credentials; the server authenticates each tunneled request.
|
|
113
155
|
- The tunnel is transparent to the app: adding relay support to a feature should not require the feature to know the relay exists — it goes through the shared runtime transport helpers.
|
|
114
156
|
- The two implementations stay byte-compatible and the wire format is versioned/negotiated so mixed client/host app versions degrade gracefully rather than break.
|
|
157
|
+
- SSH target-port routing never falls back to the default local port on a routing-table miss (see "SSH host routing").
|
|
115
158
|
|
|
116
159
|
For the operational rules that keep future changes (new WebSocket endpoints, transport refactors, terminal/voice porting) from breaking this, load the `relay-transport` skill.
|