@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-zQDlmCA-.js} +1 -1
  2. package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BmLxb00F.js} +4 -4
  3. package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-GH3vG8Z5.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-tgkSfBku.js} +1 -1
  5. package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-CyDXHPw9.js} +1 -1
  6. package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-DT2ySSn1.js} +1 -1
  7. package/dist/assets/{MobileSurface-htGkf7Ua.js → MobileSurface-Bhur0fxY.js} +1 -1
  8. package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-CJQ8RU1e.js} +1 -1
  9. package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-DiPnIwza.js} +1 -1
  10. package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-DC12_DLb.js} +1 -1
  11. package/dist/assets/SettingsView-BywZ5HTE.js +25 -0
  12. package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-zVXnRr6V.js} +1 -1
  13. package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CE3CCTsi.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-C3_vf7HM.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-CgfOR_0L.js → appThemeRegistry-Y2U1kIMh.js} +2 -2
  16. package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-CbqhDr1g.js} +1 -1
  17. package/dist/assets/{context-menu-BaxmodD8.js → context-menu-wdR3gf8a.js} +1 -1
  18. package/dist/assets/{dialog-DjYfj1Sn.js → dialog--cf4dygO.js} +1 -1
  19. package/dist/assets/{es-5Lhudh0n.js → es-DCoGZljP.js} +1 -1
  20. package/dist/assets/{fr-Q_UhFmKT.js → fr-dkEoZpPz.js} +2 -2
  21. package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BL_etF0B.js} +1 -1
  22. package/dist/assets/{insertionBoundaries-DzXMTleb.js → insertionBoundaries-CjCybFIP.js} +1 -1
  23. package/dist/assets/{ja-CzMPz7bT.js → ja-bQwSsvRr.js} +1 -1
  24. package/dist/assets/{ko-B-EslUsR.js → ko-Kq5C3hsv.js} +1 -1
  25. package/dist/assets/{main-CO6z5yRB.js → main-Bpwq1Xh1.js} +2 -2
  26. package/dist/assets/{main-LDphyc-v.js → main-DIVJIea3.js} +7 -7
  27. package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-BrYw8M4I.js} +2 -2
  28. package/dist/assets/{mobile-BktO3_4R.js → mobile-i4Pu_Tak.js} +2 -2
  29. package/dist/assets/{multirun-ZlwlkIy5.js → multirun-OJ8angjt.js} +1 -1
  30. package/dist/assets/{pl-Dnea6Ppc.js → pl-CYpMAgVn.js} +1 -1
  31. package/dist/assets/projectMeta-Dt0loPh9.js +3 -0
  32. package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-MSPTLfTm.js} +1 -1
  33. package/dist/assets/{radio-CJuhbQ5a.js → radio-SXFBBh2O.js} +1 -1
  34. package/dist/assets/{renderElectronMiniChatApp-Db8uO-6q.js → renderElectronMiniChatApp-BBb29zbe.js} +2 -2
  35. package/dist/assets/{renderMobileApp-BCV81Ryl.js → renderMobileApp-Ctj7sqAE.js} +5 -5
  36. package/dist/assets/{runtimeEndpointReset-Dg3DMy9v.js → runtimeEndpointReset-By8IWO59.js} +1 -1
  37. package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-8qgYIxgY.js} +1 -1
  38. package/dist/assets/src-DOs1ZW0T.css +2 -0
  39. package/dist/assets/{store-wa1ersRf.js → store-OURVnLSN.js} +2 -2
  40. package/dist/assets/{uk-xGc9Pzge.js → uk-cSkRAYFY.js} +1 -1
  41. package/dist/assets/{useAppFontEffects-CM0VKsdo.js → useAppFontEffects-D81CYdad.js} +3 -3
  42. package/dist/assets/{useDesktopWindowControlsLayout-Cq-r4Hcb.js → useDesktopWindowControlsLayout-BWOeSiA5.js} +1 -1
  43. package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-BNDb_82A.js} +1 -1
  44. package/dist/assets/{useMobileNavigationStore-DnzT4jO2.js → useMobileNavigationStore-DBYUoTQ0.js} +4 -4
  45. package/dist/assets/useSessionAutoCleanup-Cc_AwEoh.js +1 -0
  46. package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-DEhp2GTh.js} +1 -1
  47. package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-BKqeT_uz.js} +1 -1
  48. package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-BaNjJ8Y4.js} +1 -1
  49. package/dist/index.html +2 -2
  50. package/dist/mini-chat.html +2 -2
  51. package/dist/mobile.html +2 -2
  52. package/package.json +1 -1
  53. package/server/index.d.ts +6 -0
  54. package/server/index.js +6 -0
  55. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  56. package/server/lib/config-sync/config-sync.test.js +197 -0
  57. package/server/lib/config-sync/constants.js +39 -0
  58. package/server/lib/config-sync/contract.js +130 -0
  59. package/server/lib/config-sync/credential-auth.js +39 -0
  60. package/server/lib/config-sync/engine.js +108 -0
  61. package/server/lib/config-sync/index.js +67 -0
  62. package/server/lib/config-sync/local-backup.js +171 -0
  63. package/server/lib/config-sync/plan.js +318 -0
  64. package/server/lib/config-sync/receiver.js +330 -0
  65. package/server/lib/config-sync/routes.js +141 -0
  66. package/server/lib/config-sync/routes.test.js +154 -0
  67. package/server/lib/config-sync/scripts.js +264 -0
  68. package/server/lib/config-sync/selections.js +112 -0
  69. package/server/lib/config-sync/tar.js +35 -0
  70. package/server/lib/config-sync/target-id.js +24 -0
  71. package/server/lib/opencode/DOCUMENTATION.md +3 -0
  72. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  73. package/server/lib/opencode/core-routes.js +1 -0
  74. package/server/lib/opencode/openchamber-routes.js +7 -0
  75. package/server/lib/opencode/settings-runtime.js +82 -51
  76. package/server/lib/opencode/settings-runtime.test.js +42 -0
  77. package/server/lib/relay/DOCUMENTATION.md +1 -1
  78. package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
  79. package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
  80. package/dist/assets/src-oHrYIqHF.css +0 -2
  81. package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
@@ -39,9 +39,41 @@ export const createSettingsRuntime = (deps) => {
39
39
  normalizeStringArray,
40
40
  formatSettingsResponse,
41
41
  resolveDirectoryCandidate,
42
+ /**
43
+ * Optional late-bound exclusive runner (Electron injects after startWebUiServer).
44
+ * May be a function returning the runner, or the runner itself.
45
+ */
46
+ getRunExclusivePersist,
47
+ runExclusivePersist: initialRunExclusivePersist,
42
48
  } = deps;
43
49
 
44
50
  let persistSettingsLock = Promise.resolve();
51
+ let runExclusivePersist = typeof initialRunExclusivePersist === 'function'
52
+ ? initialRunExclusivePersist
53
+ : null;
54
+
55
+ const resolveRunExclusivePersist = () => {
56
+ if (typeof getRunExclusivePersist === 'function') {
57
+ const resolved = getRunExclusivePersist();
58
+ if (typeof resolved === 'function') return resolved;
59
+ }
60
+ return typeof runExclusivePersist === 'function' ? runExclusivePersist : null;
61
+ };
62
+
63
+ const runPersistedWrite = (work) => {
64
+ const external = resolveRunExclusivePersist();
65
+ if (external) {
66
+ return external(work);
67
+ }
68
+ const next = persistSettingsLock.then(async () => work());
69
+ // Keep the chain alive even if one writer throws.
70
+ persistSettingsLock = next.catch(() => {});
71
+ return next;
72
+ };
73
+
74
+ const setRunExclusivePersist = (fn) => {
75
+ runExclusivePersist = typeof fn === 'function' ? fn : null;
76
+ };
45
77
 
46
78
  // Orphan recovery is a one-shot best-effort scan: when orphans can't be
47
79
  // matched on first pass they stay on disk and every subsequent settings
@@ -495,7 +527,7 @@ export const createSettingsRuntime = (deps) => {
495
527
  await fsPromises.rm(tmp, { force: true });
496
528
  };
497
529
 
498
- const writeSettingsToDisk = async (settings) => {
530
+ const writeSettingsToDiskUnlocked = async (settings) => {
499
531
  try {
500
532
  await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
501
533
  // Atomic write: Electron main and ssh-manager read this file via plain
@@ -511,6 +543,8 @@ export const createSettingsRuntime = (deps) => {
511
543
  }
512
544
  };
513
545
 
546
+ const writeSettingsToDisk = async (settings) => runPersistedWrite(() => writeSettingsToDiskUnlocked(settings));
547
+
514
548
  const validateProjectEntries = async (projects) => {
515
549
  if (!Array.isArray(projects)) {
516
550
  return [];
@@ -778,67 +812,63 @@ export const createSettingsRuntime = (deps) => {
778
812
  const migration7 = migrateSettingsRemoveApprovedDirectories(migration6.settings);
779
813
  const migration8 = migrateSettingsCompactChatDefaults(migration7.settings);
780
814
  if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed || migration8.changed) {
781
- await writeSettingsToDisk(migration8.settings);
815
+ await runPersistedWrite(() => writeSettingsToDiskUnlocked(migration8.settings));
782
816
  }
783
817
  return migration8.settings;
784
818
  };
785
819
 
786
- const persistSettings = async (changes) => {
787
- persistSettingsLock = persistSettingsLock.then(async () => {
788
- // Log field names only changes can carry credentials (UI password,
789
- // client tokens) that must never reach the log file.
790
- console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
791
- const current = await readSettingsFromDisk();
792
- const sanitized = sanitizeSettingsUpdate(changes);
793
- let next = mergePersistedSettings(current, sanitized);
794
-
795
- const normalizedState = normalizeSettingsPaths(next);
796
- if (normalizedState.changed) {
797
- next = normalizedState.settings;
798
- }
820
+ const persistSettings = async (changes) => runPersistedWrite(async () => {
821
+ // Log field names only — changes can carry credentials (UI password,
822
+ // client tokens) that must never reach the log file.
823
+ console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
824
+ const current = await readSettingsFromDisk();
825
+ const sanitized = sanitizeSettingsUpdate(changes);
826
+ let next = mergePersistedSettings(current, sanitized);
799
827
 
800
- const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next);
801
- if (deterministicProjectIdMigration.changed) {
802
- next = deterministicProjectIdMigration.settings;
803
- }
828
+ const normalizedState = normalizeSettingsPaths(next);
829
+ if (normalizedState.changed) {
830
+ next = normalizedState.settings;
831
+ }
804
832
 
805
- const approvedDirectoriesMigration = migrateSettingsRemoveApprovedDirectories(next);
806
- if (approvedDirectoriesMigration.changed) {
807
- next = approvedDirectoriesMigration.settings;
808
- }
833
+ const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next);
834
+ if (deterministicProjectIdMigration.changed) {
835
+ next = deterministicProjectIdMigration.settings;
836
+ }
809
837
 
810
- // Ensure marker + new defaults exist before the first settings write lands.
811
- const compactChatDefaultsMigration = migrateSettingsCompactChatDefaults(next);
812
- if (compactChatDefaultsMigration.changed) {
813
- next = compactChatDefaultsMigration.settings;
814
- }
838
+ const approvedDirectoriesMigration = migrateSettingsRemoveApprovedDirectories(next);
839
+ if (approvedDirectoriesMigration.changed) {
840
+ next = approvedDirectoriesMigration.settings;
841
+ }
815
842
 
816
- // Validating project paths hits the filesystem for every entry, so only
817
- // do it when the incoming update actually touches the projects list —
818
- // not on every theme/window-state/etc. save.
819
- if (Object.prototype.hasOwnProperty.call(sanitized, 'projects') && Array.isArray(next.projects)) {
820
- const validated = await validateProjectEntries(next.projects);
821
- next = { ...next, projects: validated };
822
- }
843
+ // Ensure marker + new defaults exist before the first settings write lands.
844
+ const compactChatDefaultsMigration = migrateSettingsCompactChatDefaults(next);
845
+ if (compactChatDefaultsMigration.changed) {
846
+ next = compactChatDefaultsMigration.settings;
847
+ }
823
848
 
824
- if (Array.isArray(next.projects) && next.projects.length > 0) {
825
- const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
826
- const active = next.projects.find((project) => project.id === activeId) || null;
827
- if (!active) {
828
- console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
829
- next = { ...next, activeProjectId: next.projects[0].id };
830
- }
831
- } else if (next.activeProjectId) {
832
- console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
833
- next = { ...next, activeProjectId: undefined };
834
- }
849
+ // Validating project paths hits the filesystem for every entry, so only
850
+ // do it when the incoming update actually touches the projects list —
851
+ // not on every theme/window-state/etc. save.
852
+ if (Object.prototype.hasOwnProperty.call(sanitized, 'projects') && Array.isArray(next.projects)) {
853
+ const validated = await validateProjectEntries(next.projects);
854
+ next = { ...next, projects: validated };
855
+ }
835
856
 
836
- await writeSettingsToDisk(next);
837
- return formatSettingsResponse(next);
838
- });
857
+ if (Array.isArray(next.projects) && next.projects.length > 0) {
858
+ const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
859
+ const active = next.projects.find((project) => project.id === activeId) || null;
860
+ if (!active) {
861
+ console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
862
+ next = { ...next, activeProjectId: next.projects[0].id };
863
+ }
864
+ } else if (next.activeProjectId) {
865
+ console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
866
+ next = { ...next, activeProjectId: undefined };
867
+ }
839
868
 
840
- return persistSettingsLock;
841
- };
869
+ await writeSettingsToDiskUnlocked(next);
870
+ return formatSettingsResponse(next);
871
+ });
842
872
 
843
873
  return {
844
874
  readSettingsFromDisk,
@@ -846,5 +876,6 @@ export const createSettingsRuntime = (deps) => {
846
876
  readSettingsFromDiskMigrated,
847
877
  writeSettingsToDisk,
848
878
  persistSettings,
879
+ setRunExclusivePersist,
849
880
  };
850
881
  };
@@ -190,6 +190,48 @@ describe('settings runtime', () => {
190
190
  }
191
191
  });
192
192
 
193
+ it('joins an external exclusive persist runner when provided', async () => {
194
+ const { runtime, settingsFilePath, cleanup } = await createRuntime();
195
+ try {
196
+ const order = [];
197
+ let releaseOuter;
198
+ const outerGate = new Promise((resolve) => {
199
+ releaseOuter = resolve;
200
+ });
201
+
202
+ runtime.setRunExclusivePersist(async (work) => {
203
+ order.push('lock-enter');
204
+ const result = await work();
205
+ order.push('lock-exit');
206
+ return result;
207
+ });
208
+
209
+ const pending = (async () => {
210
+ order.push('outer-start');
211
+ await outerGate;
212
+ order.push('outer-before-write');
213
+ await runtime.writeSettingsToDisk({ theme: 'external' });
214
+ order.push('outer-after-write');
215
+ })();
216
+
217
+ // Without awaiting the outer gate, ensure the lock wrapper is the one used.
218
+ releaseOuter();
219
+ await pending;
220
+ expect(order).toEqual([
221
+ 'outer-start',
222
+ 'outer-before-write',
223
+ 'lock-enter',
224
+ 'lock-exit',
225
+ 'outer-after-write',
226
+ ]);
227
+ await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(
228
+ JSON.stringify({ theme: 'external' }, null, 2),
229
+ );
230
+ } finally {
231
+ await cleanup();
232
+ }
233
+ });
234
+
193
235
  it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
194
236
  const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
195
237
  const settingsFilePath = path.join(tempRoot, 'settings.json');
@@ -50,7 +50,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
50
50
  Private Relay is transparent for allowlisted HTTP paths, so most “works on LAN / Desktop, empty on mobile Relay” bugs are Host routing or client transport-identity mistakes rather than Relay framing bugs:
51
51
 
52
52
  - **Same path, real Host route required.** Clients call the ordinary `/api/...` path through the tunnel. If that route is missing on the Host process currently holding the relay claim (wrong git worktree, stale packaged Desktop build, or a backend that never registered the OpenChamber route before the OpenCode proxy/SPA fallback), the tunnel still returns HTTP 200 with SPA HTML or proxied OpenCode content. Symptom: chat and status work, Provider/model catalog does not.
53
- - **HTTP allowlist is prefix-based for `/api/`.** New REST/SSE APIs under `/api/` do not need a tunnel-host allowlist edit. New **WebSocket** paths still need both `ALLOWED_WS_PATHS` and `isUrlAuthWebSocketPath` (see the `relay-transport` skill).
53
+ - **HTTP allowlist is prefix-based for `/api/`.** New REST/SSE APIs under `/api/` do not need a tunnel-host allowlist edit. New **WebSocket** paths still need both `ALLOWED_WS_PATHS` and `isUrlAuthWebSocketPath` (see the `relay-transport` skill). Config sync (`/api/openchamber/config-sync/*`, ticket 05/06) therefore works over Relay without a tunnel-host allowlist change; identity and credential grants remain endpoint concerns (`relay:<serverId>`, pairing-settings / inbound host grant).
54
54
  - **Transport identity ≠ runtime key.** After LAN⇄relay swaps, UI catalog loaders commit only when `useConfigStore.catalogTransportIdentity` matches `getRuntimeTransportIdentity()`. `runtimeEndpointReset.ts` must write that transport fingerprint on both full endpoint reset and in-place transport reconnect. Writing `runtimeKey` instead leaves Providers empty because the stable device/instance id is shared across LAN and relay.
55
55
  - **Safe catalog projection stays Host-owned.** Provider credentials never cross the browser; Relay only carries the already-allowlisted `GET /api/config/catalog/providers` JSON. Keep Host projection, client parser bounds, and `partial` rules in sync (empty `release_date` is absent, not partial).
56
56