@openchambery/web 1.18.7-beta.2 → 1.18.7-beta.3

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 (133) hide show
  1. package/dist/assets/AgentAvatar-CVTgW_rx.js +1 -0
  2. package/dist/assets/AssistantView-CvQ91w2s.js +1 -0
  3. package/dist/assets/{ChatContainer-BHDNtLuy.js → ChatContainer-CERrtaB-.js} +41 -43
  4. package/dist/assets/ContextPanel-CLz4r1G-.js +285 -0
  5. package/dist/assets/DiagramView-DhV4Zthj.js +1 -0
  6. package/dist/assets/{FadeInOnReveal-BpIMrqet.js → FadeInOnReveal-Cy3sf2M5.js} +1 -1
  7. package/dist/assets/{Icon-C0G6afNV.js → Icon-BLWVqBee.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-B-SOb7Za.js → MarkdownRendererImpl-B4qwUu4q.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-C7OjF65p.js → MobileDetailNavigation-CuWgEThi.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-CRRrlX0l.js → MobileShareBridge-C3nay5Sq.js} +2 -2
  11. package/dist/assets/MobileSurface-D7v_ZkYO.js +2 -0
  12. package/dist/assets/{MultiRunWindow-Dx4tkVFN.js → MultiRunWindow-Cq8yyiqm.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-BpxD5W4Z.js → OnboardingScreen-CTNi2zuA.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-Bo7MF_jE.js → OverlayScrollbar-GQgJusKL.js} +1 -1
  15. package/dist/assets/SettingsView-CViKCsxJ.js +25 -0
  16. package/dist/assets/{SettingsWindow-BlKxv7c3.js → SettingsWindow-C6vwy4hH.js} +1 -1
  17. package/dist/assets/{TerminalView-xtUbWmdn.js → TerminalView-C1aX22ka.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-DUYcTYYW.js → ToolOutputDialog-BrOqj1sB.js} +4 -4
  19. package/dist/assets/appThemeRegistry-BbqImxSo.js +2 -0
  20. package/dist/assets/{button-DfF088a8.js → button-CeCkEpvn.js} +1 -1
  21. package/dist/assets/{clipboard-RoOQVnSl.js → clipboard-BXC1vZwE.js} +1 -1
  22. package/dist/assets/{collapsible-CVL-5iEB.js → collapsible-B-aagADh.js} +1 -1
  23. package/dist/assets/{desktopHosts-Dzx3McaD.js → desktopHosts-BrrYPqKq.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-B9S0Rp5s.js → desktopRelayRestore-DR60DATT.js} +1 -1
  25. package/dist/assets/diagram-Cxma_IKI.js +1 -0
  26. package/dist/assets/dialog-wraJHZQ2.js +1 -0
  27. package/dist/assets/es-DQqFAaWT.js +15 -0
  28. package/dist/assets/fileQueries-BJKLdYos.js +1 -0
  29. package/dist/assets/fr-CNfGRpc1.js +13 -0
  30. package/dist/assets/{gitApi-DsdwefuR.js → gitApi-B4z4eSZV.js} +1 -1
  31. package/dist/assets/{input-Ca2tUhgN.js → input-CyG05w-V.js} +1 -1
  32. package/dist/assets/insertionBoundaries-DCYAK5W1.js +4 -0
  33. package/dist/assets/ja-CQqehnVn.js +15 -0
  34. package/dist/assets/ko-kmwi8UJD.js +15 -0
  35. package/dist/assets/main-A8oe9r34.js +29 -0
  36. package/dist/assets/{main-CKb-YXE6.js → main-BJBKpmw3.js} +2 -2
  37. package/dist/assets/{markdown-worker-ZaHiIw5I.js → markdown-worker-DDCUZkz4.js} +1 -1
  38. package/dist/assets/{markdownTheme-DidS-jC5.js → markdownTheme-H7_7lbuh.js} +1 -1
  39. package/dist/assets/miniChat-BObOyFSV.js +2 -0
  40. package/dist/assets/mobile-Ba390Cmc.js +2 -0
  41. package/dist/assets/multirun-CMDYOrmD.js +4 -0
  42. package/dist/assets/{persistence-aCOlo6ew.js → persistence-DE9RnVcA.js} +2 -2
  43. package/dist/assets/pl-Blv5o2QA.js +15 -0
  44. package/dist/assets/platform-MjT8iqKB.js +2 -0
  45. package/dist/assets/projectMeta-uWBWFG5A.js +3 -0
  46. package/dist/assets/pt-BR-BK74t-Bd.js +15 -0
  47. package/dist/assets/radio-CEXUd4RC.js +1 -0
  48. package/dist/assets/renderElectronMiniChatApp-Bpgb2E78.js +2 -0
  49. package/dist/assets/renderMobileApp-BMwRozEC.js +23 -0
  50. package/dist/assets/runtimeConfig-hHuyauKZ.js +2 -0
  51. package/dist/assets/runtimeEndpointReset-CmMI7Xp1.js +4 -0
  52. package/dist/assets/{shikiHighlight-eMLXb99c.js → shikiHighlight-C-kTQvqd.js} +1 -1
  53. package/dist/assets/{sortable-tabs-strip-CDHNwlp9.js → sortable-tabs-strip-BdbKDDAP.js} +1 -1
  54. package/dist/assets/src-CgFGBE_A.css +2 -0
  55. package/dist/assets/store-UweBeN2y.js +15 -0
  56. package/dist/assets/{terminalApi-B36I-z14.js → terminalApi-Bnx0KvaV.js} +1 -1
  57. package/dist/assets/{tooltip-GC3dg3gO.js → tooltip-B__x-HDJ.js} +1 -1
  58. package/dist/assets/uk-6I-KXy3R.js +15 -0
  59. package/dist/assets/{url-BQHM4bC4.js → url-CTuw7Rjf.js} +1 -1
  60. package/dist/assets/{useAppFontEffects-B9Ozsfua.js → useAppFontEffects-8iAE0Yrj.js} +4 -4
  61. package/dist/assets/{useAssistantUIStore-DxZsjSPQ.js → useAssistantUIStore-D2K7ksGj.js} +1 -1
  62. package/dist/assets/{useDesktopWindowControlsLayout-QxNEOYzY.js → useDesktopWindowControlsLayout--oz1VAPt.js} +1 -1
  63. package/dist/assets/useEffectiveDirectory-DmoqXaa4.js +1 -0
  64. package/dist/assets/{useFontPreferences-D9h4M7x4.js → useFontPreferences-CWZnNRjz.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BxbEX-es.js +412 -0
  66. package/dist/assets/{useSessionAutoCleanup-CXqhEUbp.js → useSessionAutoCleanup-BwlJlhY7.js} +1 -1
  67. package/dist/assets/{useUIStore-Wyu09VUF.js → useUIStore-DOxLVeGb.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-2KOHSbaa.js → useWorkerHighlightedLines-B21D9MOD.js} +1 -1
  69. package/dist/assets/{utils-CJdWZupK.js → utils-C2R0YoC5.js} +1 -1
  70. package/dist/assets/vendor-legendapp-list-WE32lwNk.js +1 -0
  71. package/dist/assets/zh-CN-DwlzS4yK.js +15 -0
  72. package/dist/assets/zh-TW-p0VriyfT.js +15 -0
  73. package/dist/index.html +9 -9
  74. package/dist/mini-chat.html +9 -9
  75. package/dist/mobile.html +9 -9
  76. package/package.json +3 -2
  77. package/server/index.js +19 -6
  78. package/server/lib/notifications/APNS.md +88 -38
  79. package/server/lib/notifications/DOCUMENTATION.md +11 -4
  80. package/server/lib/notifications/apns-runtime.js +793 -78
  81. package/server/lib/notifications/apns-runtime.test.js +922 -0
  82. package/server/lib/notifications/apns-titles.js +212 -0
  83. package/server/lib/notifications/apns-titles.test.js +55 -0
  84. package/server/lib/notifications/routes.js +57 -1
  85. package/server/lib/notifications/runtime.js +20 -17
  86. package/server/lib/notifications/runtime.test.js +108 -0
  87. package/server/lib/opencode/DOCUMENTATION.md +1 -1
  88. package/server/lib/opencode/bootstrap-runtime.js +6 -0
  89. package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
  90. package/server/lib/opencode/routes.js +24 -5
  91. package/server/lib/opencode/routes.test.js +78 -1
  92. package/server/lib/relay/DOCUMENTATION.md +2 -2
  93. package/server/lib/relay/service.js +45 -3
  94. package/server/lib/relay/service.test.js +117 -0
  95. package/server/lib/relay/signing-key.js +1 -1
  96. package/server/lib/scheduled-tasks/DOCUMENTATION.md +27 -2
  97. package/server/lib/scheduled-tasks/runtime.js +271 -3
  98. package/server/lib/scheduled-tasks/runtime.test.js +308 -0
  99. package/server/lib/session-goal/runtime.js +2 -2
  100. package/dist/assets/AgentAvatar-CWn_73qY.js +0 -1
  101. package/dist/assets/AssistantView-BiOO5An0.js +0 -1
  102. package/dist/assets/ContextPanel-BbNBZipF.js +0 -288
  103. package/dist/assets/DiagramView-AMPWVMV0.js +0 -1
  104. package/dist/assets/MobileSurface-DmMgH78x.js +0 -2
  105. package/dist/assets/SettingsView-BDaMrSki.js +0 -25
  106. package/dist/assets/appThemeRegistry-D6qZmouf.js +0 -2
  107. package/dist/assets/dialog-BbHDXPt6.js +0 -1
  108. package/dist/assets/es-BkIvCvwj.js +0 -15
  109. package/dist/assets/fileQueries-_VUJM8AE.js +0 -1
  110. package/dist/assets/fr-tCwQ3odX.js +0 -13
  111. package/dist/assets/insertionBoundaries-M7AXIWfg.js +0 -1
  112. package/dist/assets/ja-C0rD6dwp.js +0 -15
  113. package/dist/assets/ko-CAbtZ462.js +0 -15
  114. package/dist/assets/main-QjOL36Zh.js +0 -29
  115. package/dist/assets/miniChat-C45kGqqH.js +0 -2
  116. package/dist/assets/mobile-DH8nxp1K.js +0 -2
  117. package/dist/assets/multirun-BkKb0iZv.js +0 -4
  118. package/dist/assets/pl-Bb190ba6.js +0 -15
  119. package/dist/assets/platform-DxNH_0Fj.js +0 -2
  120. package/dist/assets/projectMeta-BHtBPaxp.js +0 -3
  121. package/dist/assets/pt-BR-B6p_t3IB.js +0 -15
  122. package/dist/assets/radio-BacnmuU0.js +0 -1
  123. package/dist/assets/renderElectronMiniChatApp-Cn7umSw9.js +0 -2
  124. package/dist/assets/renderMobileApp-DZBR2t3l.js +0 -23
  125. package/dist/assets/runtimeConfig-CSAVS8gd.js +0 -2
  126. package/dist/assets/runtimeEndpointReset-BZUX3z0A.js +0 -4
  127. package/dist/assets/src-DziW4HUU.css +0 -2
  128. package/dist/assets/store-1FOY4G3z.js +0 -15
  129. package/dist/assets/uk-Dy8-6ZEj.js +0 -15
  130. package/dist/assets/useEffectiveDirectory-D3u_oih8.js +0 -1
  131. package/dist/assets/useMobileNavigationStore-CCPTHVO9.js +0 -475
  132. package/dist/assets/zh-CN-gRTE0npc.js +0 -15
  133. package/dist/assets/zh-TW-ClD4BCfW.js +0 -15
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import express from 'express';
3
3
  import request from 'supertest';
4
4
  import fs from 'fs';
@@ -125,3 +125,80 @@ describe('behavior AGENTS.md route', () => {
125
125
  readFile.mockRestore();
126
126
  });
127
127
  });
128
+
129
+ describe('opencode upgrade route', () => {
130
+ const originalFetch = globalThis.fetch;
131
+
132
+ afterEach(() => {
133
+ globalThis.fetch = originalFetch;
134
+ });
135
+
136
+ const jsonResponse = (body, status = 200, statusText = '') =>
137
+ new Response(JSON.stringify(body), {
138
+ status,
139
+ statusText,
140
+ headers: { 'Content-Type': 'application/json' },
141
+ });
142
+
143
+ const createUpgradeApp = () => {
144
+ const app = express();
145
+ app.use(express.json());
146
+ const deps = createDependencies({ formatSettingsResponse: vi.fn(() => ({})) });
147
+ deps.getOpenCodeResolutionSnapshot.mockResolvedValue({ source: 'path' });
148
+ deps.buildOpenCodeUrl.mockImplementation((pathname) => `http://opencode.test${pathname}`);
149
+ deps.refreshOpenCodeAfterConfigChange.mockResolvedValue(undefined);
150
+ registerOpenCodeRoutes(app, deps);
151
+ return { app, deps };
152
+ };
153
+
154
+ it('forwards an explicit target to OpenCode /global/upgrade', async () => {
155
+ const fetchMock = vi.fn(async (url, init) => {
156
+ expect(String(url)).toBe('http://opencode.test/global/upgrade');
157
+ expect(JSON.parse(init.body)).toEqual({ target: '1.18.26' });
158
+ return jsonResponse({ success: true, version: '1.18.26' });
159
+ });
160
+ globalThis.fetch = fetchMock;
161
+ const { app } = createUpgradeApp();
162
+
163
+ const response = await request(app)
164
+ .post('/api/opencode/upgrade')
165
+ .send({ target: 'v1.18.26' })
166
+ .expect(200);
167
+
168
+ expect(response.body).toEqual({ success: true, version: '1.18.26', restarted: true });
169
+ expect(fetchMock).toHaveBeenCalledTimes(1);
170
+ });
171
+
172
+ it('rejects an omitted target without calling OpenCode', async () => {
173
+ const fetchMock = vi.fn();
174
+ globalThis.fetch = fetchMock;
175
+ const { app } = createUpgradeApp();
176
+
177
+ const response = await request(app).post('/api/opencode/upgrade').send({}).expect(400);
178
+
179
+ expect(response.body).toEqual({
180
+ success: false,
181
+ error: 'OpenCode upgrade requires a semantic version target',
182
+ });
183
+ expect(fetchMock).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('surfaces OpenCode schema BadRequest messages instead of statusText', async () => {
187
+ const fetchMock = vi.fn(async () =>
188
+ jsonResponse(
189
+ { name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Missing' } },
190
+ 400,
191
+ 'Bad Request',
192
+ ),
193
+ );
194
+ globalThis.fetch = fetchMock;
195
+ const { app } = createUpgradeApp();
196
+
197
+ const response = await request(app)
198
+ .post('/api/opencode/upgrade')
199
+ .send({ target: 'latest' })
200
+ .expect(400);
201
+
202
+ expect(response.body).toEqual({ success: false, error: 'Expected a semantic version' });
203
+ });
204
+ });
@@ -19,9 +19,9 @@ 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 by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary 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` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `openchamber serve --relay-host`) sticky until disabled. 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.
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 by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary 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` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `openchamber serve --relay-host`) sticky until disabled. 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. `derivePushSendUrlFromRelayUrl` maps a canonical Relay `wss://`/`ws://` URL to the same host's `https://`/`http://` `/v1/push/send` path (register is `/v1/push/register-token`); `OPENCHAMBER_PUSH_RELAY_URL` on the Host overrides that derivation. The isolated Push process lives in `packages/relay-server/src/push/` and does not share Layer 1 credentials or tunnel state.
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
- - `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
24
+ - `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime. Canonical public JWK hashing matches `packages/relay-server/src/push/crypto.js`.
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.
26
26
  - `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
27
27
  - `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
@@ -54,6 +54,25 @@ export const canonicalizeRelayUrl = (value) => {
54
54
  }
55
55
  };
56
56
 
57
+ // Canonical ws(s) relay URL → Push send endpoint on the same host/port.
58
+ // Always `/v1/push/send`; never derived from window/location.
59
+ export const derivePushSendUrlFromRelayUrl = (value) => {
60
+ const canonical = canonicalizeRelayUrl(value);
61
+ if (!canonical) return null;
62
+ try {
63
+ const url = new URL(canonical);
64
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
65
+ url.pathname = '/v1/push/send';
66
+ url.username = '';
67
+ url.password = '';
68
+ url.hash = '';
69
+ url.search = '';
70
+ return url.toString();
71
+ } catch {
72
+ return null;
73
+ }
74
+ };
75
+
57
76
  const normalizeRelayUrl = (value) => {
58
77
  if (typeof value !== 'string') return DEFAULT_RELAY_URL;
59
78
  return canonicalizeRelayUrl(value) ?? DEFAULT_RELAY_URL;
@@ -63,12 +82,16 @@ const normalizeRelayUrl = (value) => {
63
82
  // your own Cloudflare account/domain). When set and valid it overrides the
64
83
  // stored setting entirely, so the host connection, the pairing offer, and the
65
84
  // status all point at it — clients then inherit it from the offer automatically.
66
- const envRelayUrlOverride = () => {
67
- const raw = process.env.OPENCHAMBER_RELAY_URL;
85
+ const envRelayUrlOverride = (env = process.env) => {
86
+ const raw = env?.OPENCHAMBER_RELAY_URL;
68
87
  if (typeof raw !== 'string' || !raw.trim()) return null;
69
88
  return canonicalizeRelayUrl(raw);
70
89
  };
71
90
 
91
+ // Env (`OPENCHAMBER_RELAY_URL`) → settings.privateRelay.relayUrl → DEFAULT_RELAY_URL.
92
+ export const resolveEffectiveRelayUrl = ({ settings = null, env = process.env } = {}) =>
93
+ envRelayUrlOverride(env) ?? normalizeRelayUrl(settings?.privateRelay?.relayUrl);
94
+
72
95
  /**
73
96
  * @param {{
74
97
  * crypto: typeof import('node:crypto'),
@@ -77,6 +100,7 @@ const envRelayUrlOverride = () => {
77
100
  * getLocalPort: () => number,
78
101
  * logger?: Pick<Console, 'warn'>,
79
102
  * canHostRelay?: () => boolean,
103
+ * onRelayUrlChanged?: () => Promise<void> | void,
80
104
  * }} deps
81
105
  */
82
106
  export const createRelayService = ({
@@ -98,6 +122,7 @@ export const createRelayService = ({
98
122
  // Host gate: false for non-host runtimes (dev server, ordinary CLI, VS Code, …).
99
123
  canHostRelay = () => isRelayHostRuntime(),
100
124
  logger = console,
125
+ onRelayUrlChanged = null,
101
126
  }) => {
102
127
  const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
103
128
 
@@ -131,13 +156,22 @@ export const createRelayService = ({
131
156
  const override = envRelayUrlOverride();
132
157
  return {
133
158
  enabled: stored?.enabled === true,
134
- relayUrl: override ?? normalizeRelayUrl(stored?.relayUrl),
159
+ relayUrl: resolveEffectiveRelayUrl({ settings }),
135
160
  // True when the endpoint is pinned by OPENCHAMBER_RELAY_URL (a self-hosted
136
161
  // relay); the stored setting is ignored while it is set.
137
162
  relayUrlLocked: override !== null,
138
163
  };
139
164
  };
140
165
 
166
+ const notifyRelayUrlChanged = async () => {
167
+ if (typeof onRelayUrlChanged !== 'function') return;
168
+ try {
169
+ await onRelayUrlChanged();
170
+ } catch (error) {
171
+ logger.warn(`[Relay] push token re-register failed: ${error?.message ?? error}`);
172
+ }
173
+ };
174
+
141
175
  const writeConfig = async (config) => {
142
176
  const settings = await readSettingsFromDiskMigrated();
143
177
  await writeSettingsToDisk({
@@ -365,6 +399,10 @@ export const createRelayService = ({
365
399
  if (!config.enabled || endpointChanged) {
366
400
  await writeConfig({ enabled: true, relayUrl });
367
401
  }
402
+ const next = await readConfig();
403
+ if (next.relayUrl !== config.relayUrl) {
404
+ await notifyRelayUrlChanged();
405
+ }
368
406
  if (endpointChanged) {
369
407
  // One Host identity can have only one live Relay control connection. A
370
408
  // custom endpoint therefore becomes the authoritative transport before
@@ -409,6 +447,10 @@ export const createRelayService = ({
409
447
  relayUrl = canonical;
410
448
  }
411
449
  await writeConfig({ enabled: true, relayUrl });
450
+ const next = await readConfig();
451
+ if (next.relayUrl !== current.relayUrl) {
452
+ await notifyRelayUrlChanged();
453
+ }
412
454
  if (hostClient) stop();
413
455
  // Explicit user action: take the machine's host claim like pairing does.
414
456
  await start(relayUrl, { claim: 'force' });
@@ -34,8 +34,10 @@ vi.mock('./host-client.js', () => ({
34
34
  import {
35
35
  createRelayService,
36
36
  DEFAULT_RELAY_URL,
37
+ derivePushSendUrlFromRelayUrl,
37
38
  isRelayHostRuntime,
38
39
  RELAY_HOST_DESKTOP_ONLY_MESSAGE,
40
+ resolveEffectiveRelayUrl,
39
41
  } from './service.js';
40
42
 
41
43
  const createSettingsHarness = () => {
@@ -60,6 +62,59 @@ const createTestRelayService = ({ canHostRelay = true, ...overrides } = {}) => {
60
62
  return { service, settings };
61
63
  };
62
64
 
65
+ describe('derivePushSendUrlFromRelayUrl', () => {
66
+ it('converts wss and ws canonical relay URLs to the fixed push send path', () => {
67
+ expect(derivePushSendUrlFromRelayUrl('wss://relay.openchamber.dev/ws')).toBe(
68
+ 'https://relay.openchamber.dev/v1/push/send',
69
+ );
70
+ expect(derivePushSendUrlFromRelayUrl('ws://127.0.0.1:8787/custom')).toBe(
71
+ 'http://127.0.0.1:8787/v1/push/send',
72
+ );
73
+ expect(derivePushSendUrlFromRelayUrl('wss://relay.example:8443/any/path?token=secret#frag')).toBe(
74
+ 'https://relay.example:8443/v1/push/send',
75
+ );
76
+ });
77
+
78
+ it('rejects userinfo and non-ws(s) URLs', () => {
79
+ expect(derivePushSendUrlFromRelayUrl('wss://user:pass@relay.example/ws')).toBeNull();
80
+ expect(derivePushSendUrlFromRelayUrl('https://relay.openchamber.dev/ws')).toBeNull();
81
+ expect(derivePushSendUrlFromRelayUrl('http://relay.example/v1/push/send')).toBeNull();
82
+ expect(derivePushSendUrlFromRelayUrl('')).toBeNull();
83
+ expect(derivePushSendUrlFromRelayUrl(null)).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe('resolveEffectiveRelayUrl', () => {
88
+ afterEach(() => {
89
+ delete process.env.OPENCHAMBER_RELAY_URL;
90
+ });
91
+
92
+ it('prefers a valid env URL, then settings, then the default', () => {
93
+ expect(resolveEffectiveRelayUrl()).toBe(DEFAULT_RELAY_URL);
94
+ expect(
95
+ resolveEffectiveRelayUrl({
96
+ settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
97
+ }),
98
+ ).toBe('wss://settings.example/ws');
99
+
100
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws?x=1#frag';
101
+ expect(
102
+ resolveEffectiveRelayUrl({
103
+ settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
104
+ }),
105
+ ).toBe('wss://env.example/ws');
106
+ });
107
+
108
+ it('ignores an invalid env URL and falls through to settings', () => {
109
+ process.env.OPENCHAMBER_RELAY_URL = 'https://not-a-relay.example/ws';
110
+ expect(
111
+ resolveEffectiveRelayUrl({
112
+ settings: { privateRelay: { relayUrl: 'wss://settings.example/ws' } },
113
+ }),
114
+ ).toBe('wss://settings.example/ws');
115
+ });
116
+ });
117
+
63
118
  describe('relay service pairing endpoint', () => {
64
119
  afterEach(() => {
65
120
  relayMocks.starts.length = 0;
@@ -142,6 +197,68 @@ describe('relay service pairing endpoint', () => {
142
197
  });
143
198
  expect(relayMocks.starts).toEqual(['wss://relay.example/custom']);
144
199
  });
200
+
201
+ it('invokes onRelayUrlChanged only when the canonical URL actually changes', async () => {
202
+ const onRelayUrlChanged = vi.fn(async () => {});
203
+ const { service } = createTestRelayService({ onRelayUrlChanged });
204
+
205
+ await service.ensureEnabledForPairing();
206
+ expect(onRelayUrlChanged).not.toHaveBeenCalled();
207
+
208
+ await service.ensureEnabledForPairing('wss://relay.example/custom');
209
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
210
+
211
+ await service.ensureEnabledForPairing('wss://relay.example/custom?x=1#frag');
212
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
213
+
214
+ const app = express();
215
+ service.registerRoutes(app);
216
+ await request(app)
217
+ .post('/api/openchamber/relay/enable')
218
+ .send({ relayUrl: 'wss://relay.example/other' })
219
+ .expect(200);
220
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(2);
221
+
222
+ await request(app)
223
+ .post('/api/openchamber/relay/enable')
224
+ .send({ relayUrl: 'wss://relay.example/other' })
225
+ .expect(200);
226
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(2);
227
+ });
228
+
229
+ it('does not invoke onRelayUrlChanged for an env-pinned relay and still uses the pin', async () => {
230
+ process.env.OPENCHAMBER_RELAY_URL = 'wss://pinned.example/ws';
231
+ const onRelayUrlChanged = vi.fn(async () => {});
232
+ const { service } = createTestRelayService({ onRelayUrlChanged });
233
+ const app = express();
234
+ service.registerRoutes(app);
235
+
236
+ const candidate = await service.ensureEnabledForPairing('wss://ignored.example/ws');
237
+ expect(candidate.relayUrl).toBe('wss://pinned.example/ws');
238
+ expect(onRelayUrlChanged).not.toHaveBeenCalled();
239
+
240
+ await request(app)
241
+ .post('/api/openchamber/relay/enable')
242
+ .send({ relayUrl: 'wss://ignored.example/ws' })
243
+ .expect(200);
244
+ expect(onRelayUrlChanged).not.toHaveBeenCalled();
245
+ expect((await service.getStatus()).relayUrl).toBe('wss://pinned.example/ws');
246
+ });
247
+
248
+ it('continues the relay switch when onRelayUrlChanged fails', async () => {
249
+ const onRelayUrlChanged = vi.fn(async () => {
250
+ throw new Error('push relay unavailable');
251
+ });
252
+ const logger = { warn: vi.fn() };
253
+ const { service } = createTestRelayService({ onRelayUrlChanged, logger });
254
+
255
+ await expect(service.ensureEnabledForPairing('wss://relay.example/custom')).resolves.toMatchObject({
256
+ relayUrl: 'wss://relay.example/custom',
257
+ });
258
+ expect(onRelayUrlChanged).toHaveBeenCalledTimes(1);
259
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('push token re-register failed'));
260
+ expect(relayMocks.starts).toEqual(['wss://relay.example/custom']);
261
+ });
145
262
  });
146
263
 
147
264
  describe('relay host runtime gate', () => {
@@ -49,7 +49,7 @@ export const getOrCreateRelaySigningKeypair = async ({ crypto, readSettingsFromD
49
49
  };
50
50
 
51
51
  // Fixed key order so the hash is stable regardless of stored JSON field order.
52
- // Byte-for-byte mirror of canonicalJwk in openchamber-website apps/api relay-auth.ts.
52
+ // Byte-for-byte mirror of canonicalPublicJwkString in packages/relay-server/src/push/crypto.js.
53
53
  /** @param {JsonWebKey} jwk */
54
54
  export const canonicalPublicJwkString = (jwk) =>
55
55
  JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
@@ -65,6 +65,7 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
65
65
  - `syncAllProjects()`
66
66
  - `syncProject(projectId)`
67
67
  - `runNow(projectId, taskId)`
68
+ - `observeSessionEvent(event)`
68
69
 
69
70
  Dependencies include optional `runHistoryStore` with:
70
71
 
@@ -212,11 +213,35 @@ Every actual run (timer or manual):
212
213
  row so the session stays openable from history. Task state persistence
213
214
  failures also finalize history as `error`. A failed history finalize
214
215
  returns error and may leave a `running` row for next-start convergence.
215
- 11. `lastSessionId` remains success-only on the task state; full association
216
- lives in the history table.
216
+ 11. `lastSessionId` is written on the task state whenever the run has a
217
+ session (success or error). If the run has no session, omit
218
+ `lastSessionId` from the patch so a previous value is preserved. Full
219
+ association still lives in the history table; history rows are not
220
+ rewritten after finalize.
217
221
 
218
222
  SSE events keep the existing `openchamber:scheduled-task-ran` shape.
219
223
 
224
+ ## Post-run continuation
225
+
226
+ After a run has finalized with a session, the user may continue chatting in
227
+ that same history session. `observeSessionEvent` corrects **only task state**
228
+ (history rows are not rewritten, `finishRun` is not called again):
229
+
230
+ - `session.status` busy/retry while `lastStatus` is `error` or `success` →
231
+ `lastStatus` `running`, clear `lastError`, keep/set `lastSessionId`
232
+ - later `session.idle` (or idle `session.status`) with a success snapshot →
233
+ `lastStatus` `success`
234
+ - do not change `lastRunAt` / `lastDurationMs` / `nextRunAt`
235
+
236
+ A live run (`runningTaskKeys`) owns settlement; the observer is a no-op while
237
+ the task is in `runningTaskKeys`. Idle correction is a no-op if `lastStatus`
238
+ is already `success`, or if the snapshot is still error/busy/unknown. Observer
239
+ failures are logged and must not throw out of the event bus.
240
+
241
+ Run start also emits `scheduled-task-ran` with `status: running` as soon as
242
+ task state is persisted, so the outer task list can show in-progress before
243
+ the session is attached.
244
+
220
245
  ## Watchdog cancel / upstream abort
221
246
 
222
247
  - Timeout path: abort the per-run signal → cancel in-flight cancellable