@openchambery/web 1.19.0-beta.9 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentAvatar-DgGHpQoD.js +1 -0
- package/dist/assets/AssistantView-WZ8qXOm6.js +1 -0
- package/dist/assets/{ChatContainer-Dvicpi3T.js → ChatContainer-CPOlhHJn.js} +41 -43
- package/dist/assets/ContextPanel-ClzJl6RQ.js +288 -0
- package/dist/assets/{DiagramView-Ct9M_fMh.js → DiagramView-BFE0XxUH.js} +1 -1
- package/dist/assets/{FadeInOnReveal-By2F9vcI.js → FadeInOnReveal-BpIMrqet.js} +1 -1
- package/dist/assets/{Icon-DpjYnXxn.js → Icon-Bs6GJIoO.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-CTTZH1DE.js → MarkdownRendererImpl-BisXjTdJ.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-BMQiOv_u.js → MobileDetailNavigation-DRiFdQXq.js} +1 -1
- package/dist/assets/{MobileShareBridge-D5J6Qnvn.js → MobileShareBridge-D-kwfbug.js} +2 -2
- package/dist/assets/MobileSurface-NsB1sKld.js +2 -0
- package/dist/assets/{MultiRunWindow-Bw_vmN_V.js → MultiRunWindow-C_Tbyfco.js} +1 -1
- package/dist/assets/{OnboardingScreen-BKmuT7AL.js → OnboardingScreen-n85WAcBc.js} +1 -1
- package/dist/assets/OverlayScrollbar-VZKKu-cu.js +2 -0
- package/dist/assets/{SettingsView-BcFNPdSl.js → SettingsView-BOrARuHr.js} +10 -10
- package/dist/assets/{SettingsWindow-5QE3UEs3.js → SettingsWindow-BmegFGj-.js} +1 -1
- package/dist/assets/{TerminalView-DyW2LZuM.js → TerminalView-BcFMX_mg.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CZPvZV_w.js → ToolOutputDialog-DN2IYmqU.js} +4 -4
- package/dist/assets/appThemeRegistry-7v5TIiNK.js +2 -0
- package/dist/assets/{button-C7OXr4ae.js → button-BkTMa7Qj.js} +1 -1
- package/dist/assets/{clipboard-DWuULPzo.js → clipboard-BL4PvBYZ.js} +1 -1
- package/dist/assets/{collapsible-DaEya6b6.js → collapsible-CVL-5iEB.js} +1 -1
- package/dist/assets/{desktopHosts-Txp4xOVQ.js → desktopHosts-Dzx3McaD.js} +1 -1
- package/dist/assets/{desktopRelayRestore-C_FSEHKM.js → desktopRelayRestore-B9S0Rp5s.js} +1 -1
- package/dist/assets/dialog-CD0j-atu.js +1 -0
- package/dist/assets/{es-BX0Vm3Ac.js → es-C-RVtNF4.js} +2 -2
- package/dist/assets/{fileQueries-B_fTkH6s.js → fileQueries-_VUJM8AE.js} +1 -1
- package/dist/assets/{fr-CuPzXTYx.js → fr-C6VZ9yt-.js} +4 -4
- package/dist/assets/{gitApi-DAUlmpqX.js → gitApi-CxPLKmZ2.js} +3 -3
- package/dist/assets/{input-BzYA603C.js → input-Ca2tUhgN.js} +1 -1
- package/dist/assets/insertionBoundaries-2Mzsx_VV.js +4 -0
- package/dist/assets/{ja-DGtzjrFO.js → ja-BdksT20v.js} +3 -3
- package/dist/assets/{ko-DZ9Di0je.js → ko-CvsczTmf.js} +3 -3
- package/dist/assets/main-C1IdVCQf.js +29 -0
- package/dist/assets/{main-IaZbd8eq.js → main-dGakBRsd.js} +2 -2
- package/dist/assets/{markdown-worker-W06-MhF5.js → markdown-worker-9Sn2jsDp.js} +1 -1
- package/dist/assets/{markdownTheme-B89dbj3N.js → markdownTheme-DidS-jC5.js} +1 -1
- package/dist/assets/{miniChat-Byg-MsGQ.js → miniChat-C8rpOcNP.js} +2 -2
- package/dist/assets/mobile-Dm22ebC9.js +2 -0
- package/dist/assets/multirun-ColPhFw1.js +4 -0
- package/dist/assets/{persistence-6sclr7B6.js → persistence-aCOlo6ew.js} +2 -2
- package/dist/assets/pl-C84UGmMr.js +15 -0
- package/dist/assets/platform-ulFAVx1H.js +2 -0
- package/dist/assets/projectMeta-CKI_msIR.js +3 -0
- package/dist/assets/{pt-BR-Brf4RMsw.js → pt-BR-DISmT3zF.js} +2 -2
- package/dist/assets/radio-DKNOd1MW.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-B8ly-dZn.js +2 -0
- package/dist/assets/renderMobileApp-OAlYU54b.js +23 -0
- package/dist/assets/{runtimeConfig-CMLxQH75.js → runtimeConfig-QFdp6Itq.js} +1 -1
- package/dist/assets/runtimeEndpointReset-MzR_-tYR.js +4 -0
- package/dist/assets/{shikiHighlight-g-Djz_ic.js → shikiHighlight-C7KkxQ9i.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-nLsPC06f.js → sortable-tabs-strip-Bl26ebY_.js} +1 -1
- package/dist/assets/src-BwToVDxi.css +2 -0
- package/dist/assets/{store-DQY8Fkb0.js → store-Dge7C5E4.js} +4 -4
- package/dist/assets/{terminalApi-BpPeXWIu.js → terminalApi-HsIEks5U.js} +1 -1
- package/dist/assets/{tooltip-D_orJMYd.js → tooltip-GC3dg3gO.js} +1 -1
- package/dist/assets/{uk-C-wmaPTi.js → uk-Ba5HBTQU.js} +2 -2
- package/dist/assets/{url-BsaBG5-1.js → url-BQHM4bC4.js} +1 -1
- package/dist/assets/{useAppFontEffects-DcZfHbUS.js → useAppFontEffects-D0FmY4C_.js} +5 -5
- package/dist/assets/{useAssistantUIStore-BHsHXCKE.js → useAssistantUIStore-DxZsjSPQ.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-FEUxMDnk.js → useDesktopWindowControlsLayout-Ce9FSwxa.js} +1 -1
- package/dist/assets/useEffectiveDirectory-C5CrdVlr.js +1 -0
- package/dist/assets/{useFontPreferences-ckzpXQaJ.js → useFontPreferences-DaFspdDd.js} +1 -1
- package/dist/assets/useMobileNavigationStore-jKG6OU5F.js +475 -0
- package/dist/assets/useSessionAutoCleanup-C6GD9W2z.js +1 -0
- package/dist/assets/{useUIStore-DzwCfRqr.js → useUIStore-Wyu09VUF.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-D6-XaqdP.js → useWorkerHighlightedLines-mc9J5erB.js} +1 -1
- package/dist/assets/{utils-B1EK5zKa.js → utils-CJdWZupK.js} +1 -1
- package/dist/assets/{zh-CN-DueRrIql.js → zh-CN-BZYwBvs-.js} +2 -2
- package/dist/assets/zh-TW-BsJuPsIi.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 +3 -2
- package/server/index.js +12 -15
- package/server/lib/config-sync/DOCUMENTATION.md +6 -7
- package/server/lib/config-sync/engine.js +0 -8
- package/server/lib/config-sync/index.js +0 -7
- package/server/lib/config-sync/receiver.js +0 -35
- package/server/lib/config-sync/routes.js +8 -41
- package/server/lib/config-sync/routes.test.js +1 -38
- package/server/lib/notifications/APNS.md +53 -38
- package/server/lib/notifications/DOCUMENTATION.md +5 -4
- package/server/lib/notifications/apns-runtime.js +235 -71
- package/server/lib/notifications/apns-runtime.test.js +447 -0
- package/server/lib/notifications/apns-titles.js +212 -0
- package/server/lib/notifications/apns-titles.test.js +55 -0
- package/server/lib/notifications/routes.js +2 -1
- package/server/lib/notifications/runtime.js +5 -13
- package/server/lib/opencode/openchamber-routes.js +1 -7
- package/server/lib/opencode/settings-helpers.js +0 -3
- package/server/lib/opencode/shutdown-runtime.js +0 -2
- package/server/lib/relay/DOCUMENTATION.md +2 -2
- package/server/lib/relay/service.js +45 -3
- package/server/lib/relay/service.test.js +117 -0
- package/server/lib/relay/signing-key.js +1 -1
- package/server/lib/session-goal/DOCUMENTATION.md +3 -3
- package/server/lib/session-goal/runtime.js +3 -3
- package/server/lib/session-title/DOCUMENTATION.md +1 -2
- package/dist/assets/AgentAvatar-DDcjamcJ.js +0 -1
- package/dist/assets/AssistantView-BipKB0Xu.js +0 -1
- package/dist/assets/ContextPanel-DxaTJbBJ.js +0 -288
- package/dist/assets/MobileSurface-Bob51m6v.js +0 -2
- package/dist/assets/OverlayScrollbar-Ch3J6ASR.js +0 -2
- package/dist/assets/appThemeRegistry-Ds7o-iDC.js +0 -2
- package/dist/assets/dialog-DPS0W9eS.js +0 -1
- package/dist/assets/insertionBoundaries-DN03Ffgf.js +0 -1
- package/dist/assets/main-rv0Iw_-o.js +0 -29
- package/dist/assets/mobile-eAdQk_4x.js +0 -2
- package/dist/assets/multirun-lZV5lnAU.js +0 -4
- package/dist/assets/pl-BuCO3qb1.js +0 -15
- package/dist/assets/platform-CbeLpKPk.js +0 -2
- package/dist/assets/projectMeta-2o-eC3Cz.js +0 -3
- package/dist/assets/radio-CHY-FZIg.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-DuK-UF8v.js +0 -2
- package/dist/assets/renderMobileApp-B-UiIevb.js +0 -23
- package/dist/assets/runtimeEndpointReset-5XhUYqlE.js +0 -4
- package/dist/assets/src-RGaBtbGz.css +0 -2
- package/dist/assets/useEffectiveDirectory-BmTAJUVK.js +0 -1
- package/dist/assets/useMobileNavigationStore-DkUlpkW4.js +0 -475
- package/dist/assets/useSessionAutoCleanup-CQmXCiFr.js +0 -1
- package/dist/assets/zh-TW-6875X80V.js +0 -15
- package/server/lib/config-sync/credential-auth.js +0 -39
- package/server/lib/session-assist/DOCUMENTATION.md +0 -62
- package/server/lib/session-assist/runtime.js +0 -376
- package/server/lib/session-assist/runtime.test.js +0 -114
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Localized scenario titles for native push (APNs / FCM). Keys match UI locales.
|
|
2
|
+
// Titles stay content-free (scenario only); session name is the body separately.
|
|
3
|
+
|
|
4
|
+
export const APNS_DEFAULT_LOCALE = 'en';
|
|
5
|
+
|
|
6
|
+
export const APNS_LOCALES = Object.freeze([
|
|
7
|
+
'en',
|
|
8
|
+
'fr',
|
|
9
|
+
'zh-CN',
|
|
10
|
+
'zh-TW',
|
|
11
|
+
'uk',
|
|
12
|
+
'es',
|
|
13
|
+
'pt-BR',
|
|
14
|
+
'ko',
|
|
15
|
+
'pl',
|
|
16
|
+
'ja',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** @typedef {'ready'|'error'|'question'|'permission'|'goal_complete'|'goal_blocked'|'goal_budget'|'update'} ApnsTitleType */
|
|
20
|
+
|
|
21
|
+
const TITLES = Object.freeze({
|
|
22
|
+
en: Object.freeze({
|
|
23
|
+
ready: 'Agent response is ready',
|
|
24
|
+
error: 'Agent hit an error',
|
|
25
|
+
question: 'Agent needs your input',
|
|
26
|
+
permission: 'Agent needs permission',
|
|
27
|
+
goal_complete: 'Goal complete',
|
|
28
|
+
goal_blocked: 'Goal blocked',
|
|
29
|
+
goal_budget: 'Goal reached its token budget',
|
|
30
|
+
update: 'Agent update',
|
|
31
|
+
session: 'Session',
|
|
32
|
+
}),
|
|
33
|
+
fr: Object.freeze({
|
|
34
|
+
ready: "La réponse de l'agent est prête",
|
|
35
|
+
error: "L'agent a rencontré une erreur",
|
|
36
|
+
question: "L'agent a besoin de votre saisie",
|
|
37
|
+
permission: "L'agent a besoin d'une autorisation",
|
|
38
|
+
goal_complete: 'Objectif terminé',
|
|
39
|
+
goal_blocked: 'Objectif bloqué',
|
|
40
|
+
goal_budget: "L'objectif a atteint son budget de tokens",
|
|
41
|
+
update: "Mise à jour de l'agent",
|
|
42
|
+
session: 'Session',
|
|
43
|
+
}),
|
|
44
|
+
'zh-CN': Object.freeze({
|
|
45
|
+
ready: '智能体回复已就绪',
|
|
46
|
+
error: '智能体遇到错误',
|
|
47
|
+
question: '智能体需要你的输入',
|
|
48
|
+
permission: '智能体需要权限',
|
|
49
|
+
goal_complete: '目标已完成',
|
|
50
|
+
goal_blocked: '目标受阻',
|
|
51
|
+
goal_budget: '目标已达 token 预算',
|
|
52
|
+
update: '智能体更新',
|
|
53
|
+
session: '会话',
|
|
54
|
+
}),
|
|
55
|
+
'zh-TW': Object.freeze({
|
|
56
|
+
ready: '智能體回覆已就緒',
|
|
57
|
+
error: '智能體遇到錯誤',
|
|
58
|
+
question: '智能體需要你的輸入',
|
|
59
|
+
permission: '智能體需要權限',
|
|
60
|
+
goal_complete: '目標已完成',
|
|
61
|
+
goal_blocked: '目標受阻',
|
|
62
|
+
goal_budget: '目標已達 token 預算',
|
|
63
|
+
update: '智能體更新',
|
|
64
|
+
session: '工作階段',
|
|
65
|
+
}),
|
|
66
|
+
uk: Object.freeze({
|
|
67
|
+
ready: 'Відповідь агента готова',
|
|
68
|
+
error: 'Агент зіткнувся з помилкою',
|
|
69
|
+
question: 'Агенту потрібне ваше введення',
|
|
70
|
+
permission: 'Агенту потрібен дозвіл',
|
|
71
|
+
goal_complete: 'Цілі досягнуто',
|
|
72
|
+
goal_blocked: 'Цілі заблоковано',
|
|
73
|
+
goal_budget: 'Ціль досягла ліміту токенів',
|
|
74
|
+
update: 'Оновлення агента',
|
|
75
|
+
session: 'Сесія',
|
|
76
|
+
}),
|
|
77
|
+
es: Object.freeze({
|
|
78
|
+
ready: 'La respuesta del agente está lista',
|
|
79
|
+
error: 'El agente encontró un error',
|
|
80
|
+
question: 'El agente necesita tu entrada',
|
|
81
|
+
permission: 'El agente necesita permiso',
|
|
82
|
+
goal_complete: 'Objetivo completado',
|
|
83
|
+
goal_blocked: 'Objetivo bloqueado',
|
|
84
|
+
goal_budget: 'El objetivo alcanzó su presupuesto de tokens',
|
|
85
|
+
update: 'Actualización del agente',
|
|
86
|
+
session: 'Sesión',
|
|
87
|
+
}),
|
|
88
|
+
'pt-BR': Object.freeze({
|
|
89
|
+
ready: 'A resposta do agente está pronta',
|
|
90
|
+
error: 'O agente encontrou um erro',
|
|
91
|
+
question: 'O agente precisa da sua entrada',
|
|
92
|
+
permission: 'O agente precisa de permissão',
|
|
93
|
+
goal_complete: 'Objetivo concluído',
|
|
94
|
+
goal_blocked: 'Objetivo bloqueado',
|
|
95
|
+
goal_budget: 'O objetivo atingiu o orçamento de tokens',
|
|
96
|
+
update: 'Atualização do agente',
|
|
97
|
+
session: 'Sessão',
|
|
98
|
+
}),
|
|
99
|
+
ko: Object.freeze({
|
|
100
|
+
ready: '에이전트 응답이 준비되었습니다',
|
|
101
|
+
error: '에이전트에서 오류가 발생했습니다',
|
|
102
|
+
question: '에이전트가 입력을 요청합니다',
|
|
103
|
+
permission: '에이전트가 권한이 필요합니다',
|
|
104
|
+
goal_complete: '목표 완료',
|
|
105
|
+
goal_blocked: '목표 차단됨',
|
|
106
|
+
goal_budget: '목표가 토큰 예산에 도달했습니다',
|
|
107
|
+
update: '에이전트 업데이트',
|
|
108
|
+
session: '세션',
|
|
109
|
+
}),
|
|
110
|
+
pl: Object.freeze({
|
|
111
|
+
ready: 'Odpowiedź agenta jest gotowa',
|
|
112
|
+
error: 'Agent napotkał błąd',
|
|
113
|
+
question: 'Agent potrzebuje Twojego wejścia',
|
|
114
|
+
permission: 'Agent potrzebuje uprawnienia',
|
|
115
|
+
goal_complete: 'Cel ukończony',
|
|
116
|
+
goal_blocked: 'Cel zablokowany',
|
|
117
|
+
goal_budget: 'Cel osiągnął budżet tokenów',
|
|
118
|
+
update: 'Aktualizacja agenta',
|
|
119
|
+
session: 'Sesja',
|
|
120
|
+
}),
|
|
121
|
+
ja: Object.freeze({
|
|
122
|
+
ready: 'エージェントの応答が準備できました',
|
|
123
|
+
error: 'エージェントでエラーが発生しました',
|
|
124
|
+
question: 'エージェントが入力を求めています',
|
|
125
|
+
permission: 'エージェントが許可を求めています',
|
|
126
|
+
goal_complete: '目標完了',
|
|
127
|
+
goal_blocked: '目標がブロックされました',
|
|
128
|
+
goal_budget: '目標がトークン予算に達しました',
|
|
129
|
+
update: 'エージェントの更新',
|
|
130
|
+
session: 'セッション',
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const matchSupportedLocale = (value) => {
|
|
135
|
+
if (!value || typeof value !== 'string') return undefined;
|
|
136
|
+
const normalized = value.toLowerCase().replace(/_/g, '-');
|
|
137
|
+
if (
|
|
138
|
+
normalized === 'zh-tw'
|
|
139
|
+
|| normalized.startsWith('zh-tw-')
|
|
140
|
+
|| normalized === 'zh-hk'
|
|
141
|
+
|| normalized.startsWith('zh-hk-')
|
|
142
|
+
|| normalized === 'zh-mo'
|
|
143
|
+
|| normalized.startsWith('zh-mo-')
|
|
144
|
+
|| normalized === 'zh-hant'
|
|
145
|
+
|| normalized.startsWith('zh-hant-')
|
|
146
|
+
) {
|
|
147
|
+
return 'zh-TW';
|
|
148
|
+
}
|
|
149
|
+
if (normalized.startsWith('zh')) return 'zh-CN';
|
|
150
|
+
if (normalized.startsWith('en')) return 'en';
|
|
151
|
+
if (normalized === 'fr' || normalized.startsWith('fr-')) return 'fr';
|
|
152
|
+
if (normalized === 'uk' || normalized.startsWith('uk-') || normalized === 'ua' || normalized.startsWith('ua-')) {
|
|
153
|
+
return 'uk';
|
|
154
|
+
}
|
|
155
|
+
if (normalized === 'es' || normalized.startsWith('es-')) return 'es';
|
|
156
|
+
if (normalized === 'pt' || normalized.startsWith('pt-')) return 'pt-BR';
|
|
157
|
+
if (normalized === 'ko' || normalized.startsWith('ko-')) return 'ko';
|
|
158
|
+
if (normalized === 'ja' || normalized.startsWith('ja-')) return 'ja';
|
|
159
|
+
if (normalized === 'pl' || normalized.startsWith('pl-')) return 'pl';
|
|
160
|
+
return undefined;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const normalizeApnsLocale = (value) => matchSupportedLocale(value) ?? APNS_DEFAULT_LOCALE;
|
|
164
|
+
|
|
165
|
+
const titlesFor = (locale) => TITLES[normalizeApnsLocale(locale)] || TITLES[APNS_DEFAULT_LOCALE];
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string|undefined|null} type
|
|
169
|
+
* @param {string|undefined|null} locale
|
|
170
|
+
*/
|
|
171
|
+
export const resolveApnsTitle = (type, locale) => {
|
|
172
|
+
const dict = titlesFor(locale);
|
|
173
|
+
if (typeof type === 'string' && Object.prototype.hasOwnProperty.call(dict, type) && type !== 'session') {
|
|
174
|
+
return dict[type];
|
|
175
|
+
}
|
|
176
|
+
return dict.update;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string|undefined|null} locale
|
|
181
|
+
*/
|
|
182
|
+
export const resolveApnsSessionFallback = (locale) => titlesFor(locale).session;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Localize a generic native-push payload for one device locale.
|
|
186
|
+
* `payload.type` selects the scenario title; `payload.sessionName` (or non-empty body) is the body.
|
|
187
|
+
* Callers that already pass a fixed `title`/`body` (tests / legacy) keep that text when `type` is absent.
|
|
188
|
+
*
|
|
189
|
+
* @param {{ type?: string, title?: string, body?: string, sessionName?: string, badge?: number, tag?: string, data?: object }} payload
|
|
190
|
+
* @param {string|undefined|null} locale
|
|
191
|
+
*/
|
|
192
|
+
export const localizeApnsPayload = (payload, locale) => {
|
|
193
|
+
const type = typeof payload?.type === 'string' ? payload.type : undefined;
|
|
194
|
+
const sessionName = typeof payload?.sessionName === 'string' && payload.sessionName.trim().length > 0
|
|
195
|
+
? payload.sessionName.trim()
|
|
196
|
+
: '';
|
|
197
|
+
const legacyBody = typeof payload?.body === 'string' && payload.body.trim().length > 0
|
|
198
|
+
? payload.body.trim()
|
|
199
|
+
: '';
|
|
200
|
+
const title = type
|
|
201
|
+
? resolveApnsTitle(type, locale)
|
|
202
|
+
: (typeof payload?.title === 'string' && payload.title.length > 0
|
|
203
|
+
? payload.title
|
|
204
|
+
: resolveApnsTitle('update', locale));
|
|
205
|
+
return {
|
|
206
|
+
title,
|
|
207
|
+
body: sessionName || legacyBody || resolveApnsSessionFallback(locale),
|
|
208
|
+
badge: payload?.badge,
|
|
209
|
+
tag: payload?.tag,
|
|
210
|
+
data: payload?.data,
|
|
211
|
+
};
|
|
212
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
APNS_DEFAULT_LOCALE,
|
|
5
|
+
APNS_LOCALES,
|
|
6
|
+
localizeApnsPayload,
|
|
7
|
+
normalizeApnsLocale,
|
|
8
|
+
resolveApnsSessionFallback,
|
|
9
|
+
resolveApnsTitle,
|
|
10
|
+
} from './apns-titles.js';
|
|
11
|
+
|
|
12
|
+
describe('apns-titles', () => {
|
|
13
|
+
it('normalizes BCP-47 tags onto supported app locales', () => {
|
|
14
|
+
expect(normalizeApnsLocale('zh-Hans-CN')).toBe('zh-CN');
|
|
15
|
+
expect(normalizeApnsLocale('zh_TW')).toBe('zh-TW');
|
|
16
|
+
expect(normalizeApnsLocale('pt-PT')).toBe('pt-BR');
|
|
17
|
+
expect(normalizeApnsLocale('ja-JP')).toBe('ja');
|
|
18
|
+
expect(normalizeApnsLocale('nope')).toBe(APNS_DEFAULT_LOCALE);
|
|
19
|
+
expect(normalizeApnsLocale(undefined)).toBe(APNS_DEFAULT_LOCALE);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('resolves every scenario type for every supported locale', () => {
|
|
23
|
+
const types = ['ready', 'error', 'question', 'permission', 'goal_complete', 'goal_blocked', 'goal_budget', 'update'];
|
|
24
|
+
for (const locale of APNS_LOCALES) {
|
|
25
|
+
for (const type of types) {
|
|
26
|
+
const title = resolveApnsTitle(type, locale);
|
|
27
|
+
expect(typeof title).toBe('string');
|
|
28
|
+
expect(title.length).toBeGreaterThan(0);
|
|
29
|
+
}
|
|
30
|
+
expect(resolveApnsSessionFallback(locale).length).toBeGreaterThan(0);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('localizes payload title by type and keeps session name as body', () => {
|
|
35
|
+
expect(localizeApnsPayload({ type: 'question', sessionName: 'Build fix' }, 'zh-CN')).toEqual({
|
|
36
|
+
title: '智能体需要你的输入',
|
|
37
|
+
body: 'Build fix',
|
|
38
|
+
badge: undefined,
|
|
39
|
+
tag: undefined,
|
|
40
|
+
data: undefined,
|
|
41
|
+
});
|
|
42
|
+
expect(localizeApnsPayload({ type: 'ready' }, 'fr').body).toBe('Session');
|
|
43
|
+
expect(localizeApnsPayload({ type: 'ready' }, 'zh-CN').body).toBe('会话');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('keeps explicit legacy title/body when type is absent', () => {
|
|
47
|
+
expect(localizeApnsPayload({ title: 'Custom', body: 'Body' }, 'ja')).toEqual({
|
|
48
|
+
title: 'Custom',
|
|
49
|
+
body: 'Body',
|
|
50
|
+
badge: undefined,
|
|
51
|
+
tag: undefined,
|
|
52
|
+
data: undefined,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -162,8 +162,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
const platform = req.body?.platform === 'android' ? 'android' : 'ios';
|
|
165
|
+
const locale = typeof req.body?.locale === 'string' ? req.body.locale.trim() : undefined;
|
|
165
166
|
if (typeof addOrUpdateApnsToken === 'function') {
|
|
166
|
-
await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform);
|
|
167
|
+
await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform, locale);
|
|
167
168
|
}
|
|
168
169
|
return res.json({ ok: true });
|
|
169
170
|
});
|
|
@@ -49,24 +49,16 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
49
49
|
|
|
50
50
|
// Generic notification for native push (per the mobile design): a fixed, scenario-based
|
|
51
51
|
// title + the session name as the body. No model/project/message content crosses the relay.
|
|
52
|
-
|
|
53
|
-
ready: 'Agent response is ready',
|
|
54
|
-
error: 'Agent hit an error',
|
|
55
|
-
question: 'Agent needs your input',
|
|
56
|
-
permission: 'Agent needs permission',
|
|
57
|
-
goal_complete: 'Goal complete',
|
|
58
|
-
goal_blocked: 'Goal blocked',
|
|
59
|
-
goal_budget: 'Goal reached its token budget',
|
|
60
|
-
};
|
|
61
|
-
|
|
52
|
+
// Title strings are localized per device token locale inside sendApnsToAllUiSessions.
|
|
62
53
|
const toApnsGenericPayload = (payload) => {
|
|
63
54
|
const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
|
|
64
55
|
const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
|
|
65
56
|
? data.sessionName.trim()
|
|
66
|
-
: '
|
|
57
|
+
: '';
|
|
58
|
+
const type = typeof data.type === 'string' ? data.type : undefined;
|
|
67
59
|
return {
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
type,
|
|
61
|
+
sessionName,
|
|
70
62
|
badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
|
|
71
63
|
tag: payload?.tag,
|
|
72
64
|
// sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content.
|
|
@@ -10,8 +10,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
10
10
|
server,
|
|
11
11
|
__dirname,
|
|
12
12
|
openchamberDataDir,
|
|
13
|
-
readSettingsFromDiskMigrated,
|
|
14
|
-
persistSettings,
|
|
15
13
|
fetchFreeZenModels,
|
|
16
14
|
getCachedZenModels,
|
|
17
15
|
sessionIndexService,
|
|
@@ -22,11 +20,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
22
20
|
|
|
23
21
|
registerSessionIndexRoutes(app, { sessionIndexService, sessionIndexSyncRuntime });
|
|
24
22
|
registerTranscriptCacheRoutes(app, { transcriptCacheService });
|
|
25
|
-
registerConfigSyncRoutes(app, {
|
|
26
|
-
readSettingsFromDiskMigrated,
|
|
27
|
-
persistSettings,
|
|
28
|
-
express,
|
|
29
|
-
});
|
|
23
|
+
registerConfigSyncRoutes(app, { express });
|
|
30
24
|
|
|
31
25
|
app.get('/api/openchamber/update-check', async (req, res) => {
|
|
32
26
|
try {
|
|
@@ -318,9 +318,6 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
318
318
|
if (typeof candidate.showReasoningTraces === 'boolean') {
|
|
319
319
|
result.showReasoningTraces = candidate.showReasoningTraces;
|
|
320
320
|
}
|
|
321
|
-
if (typeof candidate.sessionRecapEnabled === 'boolean') {
|
|
322
|
-
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
|
|
323
|
-
}
|
|
324
321
|
if (typeof candidate.sessionTitleRefreshEnabled === 'boolean') {
|
|
325
322
|
result.sessionTitleRefreshEnabled = candidate.sessionTitleRefreshEnabled;
|
|
326
323
|
}
|
|
@@ -8,7 +8,6 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
8
8
|
syncToHmrState,
|
|
9
9
|
openCodeWatcherRuntime,
|
|
10
10
|
sessionRuntime,
|
|
11
|
-
sessionAssistRuntime,
|
|
12
11
|
sessionTitleRuntime,
|
|
13
12
|
sessionGoalRuntime,
|
|
14
13
|
scheduledTasksRuntime,
|
|
@@ -43,7 +42,6 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
43
42
|
|
|
44
43
|
openCodeWatcherRuntime.stop();
|
|
45
44
|
sessionRuntime.dispose();
|
|
46
|
-
sessionAssistRuntime?.stop?.();
|
|
47
45
|
sessionTitleRuntime?.stop?.();
|
|
48
46
|
sessionGoalRuntime?.stop?.();
|
|
49
47
|
// Stop timers/queue only. Keep the process-lifetime run history store open so
|
|
@@ -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 =
|
|
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:
|
|
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
|
|
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 });
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Server-side control loop that keeps a session working toward a user-defined
|
|
4
4
|
objective stored under `metadata.openchamber.goal`, with the small model as
|
|
5
5
|
an independent progress auditor. Built on OpenChamber's backend-driven
|
|
6
|
-
architecture
|
|
6
|
+
architecture: the loop lives in
|
|
7
7
|
the web server and survives UI disconnects.
|
|
8
8
|
|
|
9
9
|
## Goal payload (`metadata.openchamber.goal`)
|
|
@@ -69,8 +69,8 @@ before touching the filesystem). Rationale: metadata rides every
|
|
|
69
69
|
|
|
70
70
|
## Flow
|
|
71
71
|
|
|
72
|
-
1. `createSessionGoalRuntime` subscribes to the global SSE hub (
|
|
73
|
-
|
|
72
|
+
1. `createSessionGoalRuntime` subscribes to the global SSE hub (it needs
|
|
73
|
+
the envelope's `directory`).
|
|
74
74
|
2. `session.status: idle` arms a 15s per-session timer; `busy`/`retry` clears
|
|
75
75
|
it. A `session.updated` carrying a fresh active goal (`turnsUsed === 0` or
|
|
76
76
|
`statusReason === 'resumed'`) arms a kickoff timer — 3s for fresh goals,
|