@openchambery/web 1.19.5-beta.3 → 1.19.5-beta.5
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-CsiLLu1E.js → AgentAvatar-DMpOrMIQ.js} +1 -1
- package/dist/assets/{AssistantView-DgpSEtGP.js → AssistantView-yBEtonie.js} +1 -1
- package/dist/assets/{ChatContainer-D0qGR4rB.js → ChatContainer-BOi9iz27.js} +4 -4
- package/dist/assets/{ContextPanel-BSXyAChA.js → ContextPanel-AdZZ4JBG.js} +3 -3
- package/dist/assets/{DiagramView-B-nKkS9W.js → DiagramView-bQvW8jaw.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-DqxqOPTd.js → MarkdownRendererImpl-CykGqBa-.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DkAeoCOb.js → MobileDetailNavigation-CE1s2ZEr.js} +1 -1
- package/dist/assets/{MobileShareBridge-DG3Ruuma.js → MobileShareBridge-C5w6oHUJ.js} +1 -1
- package/dist/assets/{MobileSurface-C4HdLHq7.js → MobileSurface-DMv5Y8M5.js} +1 -1
- package/dist/assets/{MultiRunWindow-y03QkS-Y.js → MultiRunWindow-C5_K_mdR.js} +1 -1
- package/dist/assets/{OnboardingScreen-Bv4ZepJD.js → OnboardingScreen-DoR_GB8b.js} +1 -1
- package/dist/assets/SettingsView-orus6n2g.js +25 -0
- package/dist/assets/{SettingsWindow-6aVPTn4H.js → SettingsWindow-BW9mYwHR.js} +1 -1
- package/dist/assets/{TerminalView-Dm1jRb76.js → TerminalView-BSOUHyOO.js} +1 -1
- package/dist/assets/{ToolOutputDialog-jYdZ3-3Z.js → ToolOutputDialog-nSZ8-Kjx.js} +1 -1
- package/dist/assets/{appThemeRegistry-CfsrBbrR.js → appThemeRegistry-BqqUte4z.js} +2 -2
- package/dist/assets/{clipboard-CdFyntbr.js → clipboard-CadKjjVT.js} +1 -1
- package/dist/assets/{dialog-DlS23C-q.js → dialog-Dz2MBeLn.js} +1 -1
- package/dist/assets/{es-B2ybWxIT.js → es-BZlHrH2p.js} +1 -1
- package/dist/assets/{fr-CpdWiNYi.js → fr-Dkw9WDcW.js} +1 -1
- package/dist/assets/{gitApi-4Qfl46MG.js → gitApi-DcgZ2eTY.js} +1 -1
- package/dist/assets/{insertionBoundaries-B6NLOnVF.js → insertionBoundaries-BvWyoeKg.js} +1 -1
- package/dist/assets/{ja-r-qxubSE.js → ja-DW5lcaNV.js} +1 -1
- package/dist/assets/{ko-DwsRLPip.js → ko-B9WorSEt.js} +1 -1
- package/dist/assets/{main-wifPl4Xt.js → main-5aHBuDgM.js} +2 -2
- package/dist/assets/{main-BATuFA3c.js → main-C2UbPCo7.js} +3 -3
- package/dist/assets/{miniChat-t1K5xjaS.js → miniChat-CUFkoTCJ.js} +2 -2
- package/dist/assets/{mobile-H5UBQx8z.js → mobile-B_x6fY62.js} +2 -2
- package/dist/assets/{multirun-SE4ed5un.js → multirun-uwd4aNIT.js} +1 -1
- package/dist/assets/{pl-Ch4izaEa.js → pl-CyKNdwrh.js} +1 -1
- package/dist/assets/{projectMeta-CIIRnj8d.js → projectMeta-engw2_70.js} +1 -1
- package/dist/assets/{pt-BR-CtMhi2sl.js → pt-BR-Ch6qBc5C.js} +1 -1
- package/dist/assets/{radio-ClpfwLAB.js → radio-BHGZb0sh.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-C3y0p106.js → renderElectronMiniChatApp-kwVnDxKi.js} +2 -2
- package/dist/assets/{renderMobileApp-DfeIwuDP.js → renderMobileApp-Bt-LuBXK.js} +4 -4
- package/dist/assets/{runtimeConfig-BkWkAQMN.js → runtimeConfig-_JcGrrxY.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-Dqe0EXYE.js → runtimeEndpointReset-CN0a3Q6V.js} +1 -1
- package/dist/assets/{shikiHighlight-CQMlyvSC.js → shikiHighlight-CUBUPsOF.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-BzSL5abP.js → sortable-tabs-strip-xB36tCMU.js} +1 -1
- package/dist/assets/{store-TODQ6GlW.js → store-DtS03LqZ.js} +2 -2
- package/dist/assets/{uk-BDy81LTC.js → uk-D1ao_8g2.js} +1 -1
- package/dist/assets/{useAppFontEffects-BLl_ZJIz.js → useAppFontEffects-w2z4tQjp.js} +2 -2
- package/dist/assets/{useDesktopWindowControlsLayout-BCQ5IgXO.js → useDesktopWindowControlsLayout-CsOlndNX.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DBYdr3Zv.js → useEffectiveDirectory-DztZ8U3M.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-BIaXj4t-.js → useMobileNavigationStore-BzOSycct.js} +5 -5
- package/dist/assets/{useSessionAutoCleanup-xmyar0m0.js → useSessionAutoCleanup-PXfozK-R.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-BWNMQhuA.js → useWorkerHighlightedLines-B8JOB6wA.js} +1 -1
- package/dist/assets/{zh-CN-DreKLxqQ.js → zh-CN-BXlKlFLt.js} +1 -1
- package/dist/assets/{zh-TW-CUSfDrKD.js → zh-TW-DJF4ZBft.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/index.js +40 -0
- package/server/lib/opencode/core-routes.js +56 -23
- package/server/lib/opencode/core-routes.test.js +206 -6
- package/server/lib/relay/DOCUMENTATION.md +2 -1
- package/server/lib/relay/service.js +223 -71
- package/server/lib/relay/service.test.js +96 -13
- package/dist/assets/SettingsView-BR7OVpEL.js +0 -25
package/dist/mini-chat.html
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
77
|
+
<script type="module" crossorigin src="/assets/miniChat-CUFkoTCJ.js"></script>
|
|
78
78
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
79
79
|
<link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
|
|
80
80
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-Bnx0KvaV.js">
|
|
92
92
|
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
93
93
|
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
|
|
94
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
94
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-_JcGrrxY.js">
|
|
95
95
|
<link rel="stylesheet" crossorigin href="/assets/src-CD7-ovJ5.css">
|
|
96
96
|
</head>
|
|
97
97
|
<body class="h-full bg-background text-foreground">
|
package/dist/mobile.html
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
65
|
|
|
66
|
-
<script type="module" crossorigin src="/assets/mobile-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-B_x6fY62.js"></script>
|
|
67
67
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
68
68
|
<link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
|
|
69
69
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-Bnx0KvaV.js">
|
|
81
81
|
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
82
82
|
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
|
|
83
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
83
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-_JcGrrxY.js">
|
|
84
84
|
<link rel="stylesheet" crossorigin href="/assets/src-CD7-ovJ5.css">
|
|
85
85
|
</head>
|
|
86
86
|
<body class="h-full bg-background text-foreground">
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -1419,6 +1419,29 @@ async function main(options = {}) {
|
|
|
1419
1419
|
// Re-evaluate the relay lifecycle after pairing/device changes (a revoked or
|
|
1420
1420
|
// redeemed device can flip relay demand on or off).
|
|
1421
1421
|
reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
|
|
1422
|
+
// Canonical endpoints the host relay currently uses (primary first).
|
|
1423
|
+
// Pairing-session requests from paired desktop clients may carry a relayUrl
|
|
1424
|
+
// only when it matches one of these (an echo of an advertised endpoint,
|
|
1425
|
+
// never a re-point or an extension).
|
|
1426
|
+
getEffectiveRelayUrls: async () => {
|
|
1427
|
+
if (!relayServiceInstance) return [];
|
|
1428
|
+
try {
|
|
1429
|
+
const status = await relayServiceInstance.getStatus();
|
|
1430
|
+
return Array.isArray(status?.relayUrls) ? status.relayUrls : [];
|
|
1431
|
+
} catch {
|
|
1432
|
+
return [];
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1435
|
+
// Every configured relay endpoint as a pairing candidate (multi-relay),
|
|
1436
|
+
// for the connection-candidates refresh.
|
|
1437
|
+
getRelayPairingCandidates: async () => {
|
|
1438
|
+
if (!relayServiceInstance) return [];
|
|
1439
|
+
try {
|
|
1440
|
+
return await relayServiceInstance.getPairingCandidates();
|
|
1441
|
+
} catch {
|
|
1442
|
+
return [];
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1422
1445
|
getPairingTransports: async (req) => {
|
|
1423
1446
|
const transports = resolvePairingTransports(req);
|
|
1424
1447
|
if (!relayServiceInstance || !transports.relayAvailable) return transports;
|
|
@@ -1426,6 +1449,7 @@ async function main(options = {}) {
|
|
|
1426
1449
|
return {
|
|
1427
1450
|
...transports,
|
|
1428
1451
|
relayUrl: relay.relayUrl,
|
|
1452
|
+
relayUrls: relay.relayUrls,
|
|
1429
1453
|
relayUrlLocked: relay.relayUrlLocked,
|
|
1430
1454
|
};
|
|
1431
1455
|
},
|
|
@@ -1516,6 +1540,22 @@ async function main(options = {}) {
|
|
|
1516
1540
|
onRelayUrlChanged: async () => {
|
|
1517
1541
|
await apnsRuntime.reRegisterAllTokens();
|
|
1518
1542
|
},
|
|
1543
|
+
// Owner gate for relay-endpoint management: UI session or the local desktop
|
|
1544
|
+
// shell. Paired remote clients (desktops included) are NOT endpoint owners
|
|
1545
|
+
// — adding/removing endpoints is the same trust boundary as re-pointing
|
|
1546
|
+
// the primary endpoint.
|
|
1547
|
+
isOwnerRequest: async (req, res) => {
|
|
1548
|
+
if (typeof uiAuthController?.resolveAuthContext !== 'function') return false;
|
|
1549
|
+
try {
|
|
1550
|
+
const context = await uiAuthController.resolveAuthContext(req, res, { allowClientAuth: true, allowUrlToken: false });
|
|
1551
|
+
if (context?.type === 'session') return true;
|
|
1552
|
+
if (context?.type === 'client') {
|
|
1553
|
+
return context.client?.clientKind === 'desktop-local';
|
|
1554
|
+
}
|
|
1555
|
+
} catch {
|
|
1556
|
+
}
|
|
1557
|
+
return false;
|
|
1558
|
+
},
|
|
1519
1559
|
});
|
|
1520
1560
|
relayServiceInstance = relayService;
|
|
1521
1561
|
relayService.registerRoutes(app);
|
|
@@ -390,6 +390,16 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
390
390
|
// hostEncPubJwk, priority }) when the host relay is enabled, else null.
|
|
391
391
|
// Injected lazily because the relay service is constructed after these routes.
|
|
392
392
|
getRelayPairingCandidate = async () => null,
|
|
393
|
+
// All configured relay endpoints (canonical ws(s)://…), primary first, or
|
|
394
|
+
// [] when relay status is unavailable. Gates remote-bearer pairing requests
|
|
395
|
+
// that carry a relayUrl: paired desktops may only use an endpoint the host
|
|
396
|
+
// already has (echo of any configured endpoint, never a re-point).
|
|
397
|
+
getEffectiveRelayUrls = async () => [],
|
|
398
|
+
// Candidates for EVERY configured relay endpoint (multi-relay), or null /
|
|
399
|
+
// a non-array when only the single-endpoint accessor is available. Feeds
|
|
400
|
+
// the connection-candidates refresh so paired devices learn the full
|
|
401
|
+
// reachable endpoint set.
|
|
402
|
+
getRelayPairingCandidates = async () => null,
|
|
393
403
|
// Re-evaluate the relay lifecycle after pairing/device changes.
|
|
394
404
|
reconcileRelay = async () => {},
|
|
395
405
|
// Returns { local, lan, relayAvailable } — the direct transport URLs the
|
|
@@ -424,6 +434,15 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
424
434
|
}
|
|
425
435
|
};
|
|
426
436
|
|
|
437
|
+
// Which paired clients may act as operators of this server's device fleet:
|
|
438
|
+
// create pairing links, list every device, and revoke other devices. The
|
|
439
|
+
// desktop shell's local client is the in-process operator; a paired `desktop`
|
|
440
|
+
// client is granted the same trust at pairing time (parity with the SSH
|
|
441
|
+
// attach flow, where the connecting desktop authenticates as the operator).
|
|
442
|
+
// Mobile (and any other) paired clients stay scoped to their own record.
|
|
443
|
+
const canManageRemoteClients = (client) =>
|
|
444
|
+
client?.clientKind === 'desktop-local' || client?.clientKind === 'desktop';
|
|
445
|
+
|
|
427
446
|
const runWithClientManagementAuth = async (req, res, next, handler) => {
|
|
428
447
|
try {
|
|
429
448
|
if (typeof uiAuthController.resolveAuthContext === 'function') {
|
|
@@ -458,7 +477,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
458
477
|
}
|
|
459
478
|
if (context?.type === 'client') {
|
|
460
479
|
const client = await clientRecordFromAuthContext(context);
|
|
461
|
-
if (client
|
|
480
|
+
if (canManageRemoteClients(client)) {
|
|
462
481
|
await handler({ ...context, client });
|
|
463
482
|
return;
|
|
464
483
|
}
|
|
@@ -779,10 +798,10 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
779
798
|
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
|
780
799
|
if (authContext.type === 'client') {
|
|
781
800
|
const client = await clientRecordFromAuthContext(authContext);
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
if (client
|
|
801
|
+
// Operator clients (local desktop shell, paired desktops) manage the
|
|
802
|
+
// whole device fleet; every other client token is scoped to its own
|
|
803
|
+
// record.
|
|
804
|
+
if (!canManageRemoteClients(client)) {
|
|
786
805
|
return res.json({ clients: client ? [client] : [] });
|
|
787
806
|
}
|
|
788
807
|
}
|
|
@@ -807,9 +826,9 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
807
826
|
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
|
808
827
|
if (authContext.type === 'client') {
|
|
809
828
|
const actingClient = await clientRecordFromAuthContext(authContext);
|
|
810
|
-
//
|
|
811
|
-
// tokens may only revoke themselves.
|
|
812
|
-
if (actingClient
|
|
829
|
+
// Operator clients (local desktop shell, paired desktops) may revoke
|
|
830
|
+
// any device; other client tokens may only revoke themselves.
|
|
831
|
+
if (!canManageRemoteClients(actingClient)) {
|
|
813
832
|
const clientId = clientIdFromAuthContext(authContext);
|
|
814
833
|
if (!clientId || clientId !== req.params?.id) {
|
|
815
834
|
return res.status(403).json({ revoked: false, error: 'Client tokens can only revoke themselves' });
|
|
@@ -829,9 +848,10 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
829
848
|
await runWithClientManagementAuth(req, res, next, async (authContext) => {
|
|
830
849
|
if (authContext.type === 'client') {
|
|
831
850
|
const actingClient = await clientRecordFromAuthContext(authContext);
|
|
832
|
-
// Purging revoked devices is a whole-server management action; only
|
|
833
|
-
//
|
|
834
|
-
|
|
851
|
+
// Purging revoked devices is a whole-server management action; only
|
|
852
|
+
// operator clients (local desktop shell, paired desktops) or a UI
|
|
853
|
+
// session may do it.
|
|
854
|
+
if (!canManageRemoteClients(actingClient)) {
|
|
835
855
|
return res.status(403).json({ purged: 0, error: 'Client tokens cannot purge revoked devices' });
|
|
836
856
|
}
|
|
837
857
|
}
|
|
@@ -843,22 +863,25 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
843
863
|
|
|
844
864
|
app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => {
|
|
845
865
|
await runWithClientCreateAuth(req, res, next, async (authContext) => {
|
|
846
|
-
// Changing the Host Relay endpoint is an owner-UI or local desktop-shell
|
|
847
|
-
// action. Paired remote clients never reach this handler (create-auth only
|
|
848
|
-
// admits session + desktop-local); keep the kind check so a future auth
|
|
849
|
-
// widening cannot re-point the Host relay from a remote bearer.
|
|
850
|
-
if (req.body?.relayUrl !== undefined && authContext?.type === 'client') {
|
|
851
|
-
const actingClient = authContext.client || await clientRecordFromAuthContext(authContext);
|
|
852
|
-
if (actingClient?.clientKind !== 'desktop-local') {
|
|
853
|
-
return res.status(403).json({ error: 'Only the owner UI session can set a custom Relay endpoint' });
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
866
|
const requestedRelayUrl = req.body?.relayUrl === undefined
|
|
857
867
|
? undefined
|
|
858
868
|
: normalizeRequestedRelayUrl(req.body.relayUrl);
|
|
859
869
|
if (req.body?.relayUrl !== undefined && !requestedRelayUrl) {
|
|
860
870
|
return res.status(400).json({ error: 'Relay URL must use ws:// or wss://' });
|
|
861
871
|
}
|
|
872
|
+
// Changing the Host Relay endpoint set is an owner-UI or local
|
|
873
|
+
// desktop-shell action. Paired desktop operators reach this handler too,
|
|
874
|
+
// but may only name an endpoint the host already uses — anything else
|
|
875
|
+
// would re-point (or extend) the Host relay from a remote bearer.
|
|
876
|
+
if (requestedRelayUrl !== undefined && authContext?.type === 'client') {
|
|
877
|
+
const actingClient = authContext.client || await clientRecordFromAuthContext(authContext);
|
|
878
|
+
if (actingClient?.clientKind !== 'desktop-local') {
|
|
879
|
+
const effectiveRelayUrls = await getEffectiveRelayUrls();
|
|
880
|
+
if (!Array.isArray(effectiveRelayUrls) || !effectiveRelayUrls.includes(requestedRelayUrl)) {
|
|
881
|
+
return res.status(403).json({ error: 'Only the owner UI session can set a custom Relay endpoint' });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
862
885
|
const candidates = await pairingServerCandidates(req, {
|
|
863
886
|
preferredServerUrl: req.body?.serverUrl,
|
|
864
887
|
includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
|
|
@@ -906,8 +929,18 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
906
929
|
if (normalized) candidates.push({ type: 'lan', url: normalized, priority: 10 });
|
|
907
930
|
}
|
|
908
931
|
try {
|
|
909
|
-
|
|
910
|
-
|
|
932
|
+
// Multi-relay: a paired device learns EVERY configured endpoint so a
|
|
933
|
+
// lost/expensive domain can fall back to the other (the client still
|
|
934
|
+
// keeps its per-connection instance identity — serverId + relayUrl).
|
|
935
|
+
const relayCandidates = await getRelayPairingCandidates();
|
|
936
|
+
if (Array.isArray(relayCandidates)) {
|
|
937
|
+
for (const relayCandidate of relayCandidates) {
|
|
938
|
+
if (relayCandidate) candidates.push(relayCandidate);
|
|
939
|
+
}
|
|
940
|
+
} else {
|
|
941
|
+
const relayCandidate = await getRelayPairingCandidate({ ensureEnabled: false });
|
|
942
|
+
if (relayCandidate) candidates.push(relayCandidate);
|
|
943
|
+
}
|
|
911
944
|
} catch {
|
|
912
945
|
// Relay status failure must not break the direct-candidate refresh.
|
|
913
946
|
}
|
|
@@ -915,13 +915,17 @@ describe('core-routes', () => {
|
|
|
915
915
|
]);
|
|
916
916
|
});
|
|
917
917
|
|
|
918
|
-
it('
|
|
919
|
-
const getRelayPairingCandidate = vi.fn()
|
|
918
|
+
it('lets paired desktop clients create pairing sessions like the local owner (relay-attached parity)', async () => {
|
|
919
|
+
const getRelayPairingCandidate = vi.fn(async () => ({
|
|
920
|
+
type: 'relay',
|
|
921
|
+
relayUrl: 'wss://relay.example/ws',
|
|
922
|
+
serverId: 'srv_desktop',
|
|
923
|
+
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' },
|
|
924
|
+
priority: 30,
|
|
925
|
+
}));
|
|
920
926
|
const { app, dependencies } = createPairingRouteApp({
|
|
921
927
|
getRelayPairingCandidate,
|
|
922
928
|
uiAuthController: {
|
|
923
|
-
// Force a remote desktop client past create-auth so the relayUrl gate is
|
|
924
|
-
// exercised independently of the desktop-local create-auth admission rule.
|
|
925
929
|
resolveAuthContext: vi.fn(async () => ({
|
|
926
930
|
type: 'client',
|
|
927
931
|
clientId: 'remote-desktop-1',
|
|
@@ -943,15 +947,152 @@ describe('core-routes', () => {
|
|
|
943
947
|
},
|
|
944
948
|
});
|
|
945
949
|
|
|
950
|
+
const response = await request(app)
|
|
951
|
+
.post('/api/client-auth/pairing/sessions')
|
|
952
|
+
.set('Host', 'runtime.example')
|
|
953
|
+
.send({ label: 'Pair phone', includeRelay: true })
|
|
954
|
+
.expect(201);
|
|
955
|
+
|
|
956
|
+
expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith({
|
|
957
|
+
label: 'Pair phone',
|
|
958
|
+
allowedClientKinds: undefined,
|
|
959
|
+
createdByClientId: 'remote-desktop-1',
|
|
960
|
+
usesRelay: true,
|
|
961
|
+
});
|
|
962
|
+
expect(response.body.server.candidates).toEqual([
|
|
963
|
+
{ type: 'lan', url: 'http://runtime.example', priority: 10 },
|
|
964
|
+
expect.objectContaining({ type: 'relay', relayUrl: 'wss://relay.example/ws' }),
|
|
965
|
+
]);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it('rejects pairing-session creation from mobile client tokens', async () => {
|
|
969
|
+
const { app, dependencies } = createPairingRouteApp({
|
|
970
|
+
uiAuthController: {
|
|
971
|
+
resolveAuthContext: vi.fn(async () => ({
|
|
972
|
+
type: 'client',
|
|
973
|
+
clientId: 'phone-1',
|
|
974
|
+
client: { id: 'phone-1', clientKind: 'mobile' },
|
|
975
|
+
})),
|
|
976
|
+
requireAuth: vi.fn((_req, _res, next) => next()),
|
|
977
|
+
requireSessionAuth: vi.fn((_req, _res, next) => next()),
|
|
978
|
+
handleSessionStatus: vi.fn(),
|
|
979
|
+
handleSessionCreate: vi.fn(),
|
|
980
|
+
handleUrlAuthToken: vi.fn(),
|
|
981
|
+
handlePasskeyStatus: vi.fn(),
|
|
982
|
+
handlePasskeyAuthenticationOptions: vi.fn(),
|
|
983
|
+
handlePasskeyAuthenticationVerify: vi.fn(),
|
|
984
|
+
handlePasskeyRegistrationOptions: vi.fn(),
|
|
985
|
+
handlePasskeyRegistrationVerify: vi.fn(),
|
|
986
|
+
handlePasskeyList: vi.fn(),
|
|
987
|
+
handlePasskeyRevoke: vi.fn(),
|
|
988
|
+
handleResetAuth: vi.fn(),
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
|
|
946
992
|
await request(app)
|
|
947
993
|
.post('/api/client-auth/pairing/sessions')
|
|
948
|
-
.send({
|
|
994
|
+
.send({ label: 'Pair phone' })
|
|
949
995
|
.expect(403, { error: 'Client tokens cannot create remote clients' });
|
|
950
996
|
|
|
997
|
+
expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled();
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it('forbids paired desktop clients from re-pointing the Relay endpoint when pairing', async () => {
|
|
1001
|
+
const getRelayPairingCandidate = vi.fn();
|
|
1002
|
+
const getEffectiveRelayUrls = vi.fn(async () => ['wss://relay.example/ws']);
|
|
1003
|
+
const { app, dependencies } = createPairingRouteApp({
|
|
1004
|
+
getRelayPairingCandidate,
|
|
1005
|
+
getEffectiveRelayUrls,
|
|
1006
|
+
uiAuthController: {
|
|
1007
|
+
resolveAuthContext: vi.fn(async () => ({
|
|
1008
|
+
type: 'client',
|
|
1009
|
+
clientId: 'remote-desktop-1',
|
|
1010
|
+
client: { id: 'remote-desktop-1', clientKind: 'desktop' },
|
|
1011
|
+
})),
|
|
1012
|
+
requireAuth: vi.fn((_req, _res, next) => next()),
|
|
1013
|
+
requireSessionAuth: vi.fn((_req, _res, next) => next()),
|
|
1014
|
+
handleSessionStatus: vi.fn(),
|
|
1015
|
+
handleSessionCreate: vi.fn(),
|
|
1016
|
+
handleUrlAuthToken: vi.fn(),
|
|
1017
|
+
handlePasskeyStatus: vi.fn(),
|
|
1018
|
+
handlePasskeyAuthenticationOptions: vi.fn(),
|
|
1019
|
+
handlePasskeyAuthenticationVerify: vi.fn(),
|
|
1020
|
+
handlePasskeyRegistrationOptions: vi.fn(),
|
|
1021
|
+
handlePasskeyRegistrationVerify: vi.fn(),
|
|
1022
|
+
handlePasskeyList: vi.fn(),
|
|
1023
|
+
handlePasskeyRevoke: vi.fn(),
|
|
1024
|
+
handleResetAuth: vi.fn(),
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
await request(app)
|
|
1029
|
+
.post('/api/client-auth/pairing/sessions')
|
|
1030
|
+
.send({ includeRelay: true, relayUrl: 'wss://attacker.example/ws' })
|
|
1031
|
+
.expect(403, { error: 'Only the owner UI session can set a custom Relay endpoint' });
|
|
1032
|
+
|
|
951
1033
|
expect(getRelayPairingCandidate).not.toHaveBeenCalled();
|
|
952
1034
|
expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled();
|
|
953
1035
|
});
|
|
954
1036
|
|
|
1037
|
+
it('lets paired desktop clients echo the current Relay endpoint when pairing', async () => {
|
|
1038
|
+
const getRelayPairingCandidate = vi.fn(async ({ relayUrl }) => ({
|
|
1039
|
+
type: 'relay',
|
|
1040
|
+
relayUrl,
|
|
1041
|
+
serverId: 'srv_desktop',
|
|
1042
|
+
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' },
|
|
1043
|
+
priority: 30,
|
|
1044
|
+
}));
|
|
1045
|
+
const getEffectiveRelayUrls = vi.fn(async () => ['wss://relay.example/ws', 'wss://relay2.example/ws']);
|
|
1046
|
+
const { app } = createPairingRouteApp({
|
|
1047
|
+
getRelayPairingCandidate,
|
|
1048
|
+
getEffectiveRelayUrls,
|
|
1049
|
+
uiAuthController: {
|
|
1050
|
+
resolveAuthContext: vi.fn(async () => ({
|
|
1051
|
+
type: 'client',
|
|
1052
|
+
clientId: 'remote-desktop-1',
|
|
1053
|
+
client: { id: 'remote-desktop-1', clientKind: 'desktop' },
|
|
1054
|
+
})),
|
|
1055
|
+
requireAuth: vi.fn((_req, _res, next) => next()),
|
|
1056
|
+
requireSessionAuth: vi.fn((_req, _res, next) => next()),
|
|
1057
|
+
handleSessionStatus: vi.fn(),
|
|
1058
|
+
handleSessionCreate: vi.fn(),
|
|
1059
|
+
handleUrlAuthToken: vi.fn(),
|
|
1060
|
+
handlePasskeyStatus: vi.fn(),
|
|
1061
|
+
handlePasskeyAuthenticationOptions: vi.fn(),
|
|
1062
|
+
handlePasskeyAuthenticationVerify: vi.fn(),
|
|
1063
|
+
handlePasskeyRegistrationOptions: vi.fn(),
|
|
1064
|
+
handlePasskeyRegistrationVerify: vi.fn(),
|
|
1065
|
+
handlePasskeyList: vi.fn(),
|
|
1066
|
+
handlePasskeyRevoke: vi.fn(),
|
|
1067
|
+
handleResetAuth: vi.fn(),
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
const response = await request(app)
|
|
1072
|
+
.post('/api/client-auth/pairing/sessions')
|
|
1073
|
+
.send({ includeRelay: true, includeDirect: false, relayUrl: 'wss://relay.example/ws' })
|
|
1074
|
+
.expect(201);
|
|
1075
|
+
|
|
1076
|
+
expect(getRelayPairingCandidate).toHaveBeenCalledWith({
|
|
1077
|
+
ensureEnabled: true,
|
|
1078
|
+
relayUrl: 'wss://relay.example/ws',
|
|
1079
|
+
});
|
|
1080
|
+
expect(response.body.server.candidates).toEqual([
|
|
1081
|
+
expect.objectContaining({ type: 'relay', relayUrl: 'wss://relay.example/ws' }),
|
|
1082
|
+
]);
|
|
1083
|
+
|
|
1084
|
+
// Multi-relay: any CONFIGURED endpoint is fair game for a paired desktop
|
|
1085
|
+
// (choosing between the host's endpoints is not a re-point).
|
|
1086
|
+
await request(app)
|
|
1087
|
+
.post('/api/client-auth/pairing/sessions')
|
|
1088
|
+
.send({ includeRelay: true, includeDirect: false, relayUrl: 'wss://relay2.example/ws' })
|
|
1089
|
+
.expect(201);
|
|
1090
|
+
expect(getRelayPairingCandidate).toHaveBeenLastCalledWith({
|
|
1091
|
+
ensureEnabled: true,
|
|
1092
|
+
relayUrl: 'wss://relay2.example/ws',
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
|
|
955
1096
|
it('still lets desktop-local create pairing sessions without a custom Relay endpoint', async () => {
|
|
956
1097
|
const getRelayPairingCandidate = vi.fn(async () => null);
|
|
957
1098
|
const { app, dependencies } = createPairingRouteApp({
|
|
@@ -1287,6 +1428,25 @@ describe('client auth routes', () => {
|
|
|
1287
1428
|
expect(response.body.candidates).toEqual([]);
|
|
1288
1429
|
});
|
|
1289
1430
|
|
|
1431
|
+
it('returns every configured relay endpoint in the candidates refresh (multi-relay)', async () => {
|
|
1432
|
+
const app = express();
|
|
1433
|
+
const relayCandidates = [
|
|
1434
|
+
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'server-abc', hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }, priority: 30 },
|
|
1435
|
+
{ type: 'relay', relayUrl: 'wss://relay2.example/ws', serverId: 'server-abc', hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }, priority: 30 },
|
|
1436
|
+
];
|
|
1437
|
+
const dependencies = {
|
|
1438
|
+
...createDependencies({ resolveAuthContext: async () => ({ type: 'client', clientId: 'client-1' }) }),
|
|
1439
|
+
getDirectCandidateUrls: () => [],
|
|
1440
|
+
getRelayPairingCandidates: async () => relayCandidates,
|
|
1441
|
+
getServerId: async () => 'server-abc',
|
|
1442
|
+
};
|
|
1443
|
+
registerAuthAndAccessRoutes(app, dependencies);
|
|
1444
|
+
|
|
1445
|
+
const response = await request(app).get('/api/client-auth/connection/candidates');
|
|
1446
|
+
expect(response.status).toBe(200);
|
|
1447
|
+
expect(response.body.candidates).toEqual(relayCandidates);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1290
1450
|
it('scopes non-desktop client credentials to list and revoke only themselves', async () => {
|
|
1291
1451
|
const app = express();
|
|
1292
1452
|
let authContext = { type: 'session' };
|
|
@@ -1355,7 +1515,47 @@ describe('client auth routes', () => {
|
|
|
1355
1515
|
expect(purged.body.purged).toBe(1);
|
|
1356
1516
|
});
|
|
1357
1517
|
|
|
1358
|
-
it('
|
|
1518
|
+
it('lets paired desktop clients list and revoke every device (relay-attached parity)', async () => {
|
|
1519
|
+
const app = express();
|
|
1520
|
+
let authContext = { type: 'session' };
|
|
1521
|
+
const dependencies = createDependencies({
|
|
1522
|
+
resolveAuthContext: async () => authContext,
|
|
1523
|
+
});
|
|
1524
|
+
registerAuthAndAccessRoutes(app, dependencies);
|
|
1525
|
+
|
|
1526
|
+
const localDesktop = await request(app)
|
|
1527
|
+
.post('/api/client-auth/clients')
|
|
1528
|
+
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
|
|
1529
|
+
const pairedDesktop = await request(app)
|
|
1530
|
+
.post('/api/client-auth/clients')
|
|
1531
|
+
.send({ label: 'Study desktop', clientKind: 'desktop' });
|
|
1532
|
+
const phone = await request(app)
|
|
1533
|
+
.post('/api/client-auth/clients')
|
|
1534
|
+
.send({ label: 'Phone', clientKind: 'mobile' });
|
|
1535
|
+
|
|
1536
|
+
// A paired desktop (connected over relay) manages all devices like the
|
|
1537
|
+
// local desktop shell — SSH-attach parity for relay-attached hosts.
|
|
1538
|
+
authContext = { type: 'client', clientId: pairedDesktop.body.client.id, client: pairedDesktop.body.client };
|
|
1539
|
+
|
|
1540
|
+
const listed = await request(app).get('/api/client-auth/clients');
|
|
1541
|
+
expect(listed.status).toBe(200);
|
|
1542
|
+
const listedIds = listed.body.clients.map((client) => client.id).sort();
|
|
1543
|
+
expect(listedIds).toEqual([
|
|
1544
|
+
localDesktop.body.client.id,
|
|
1545
|
+
pairedDesktop.body.client.id,
|
|
1546
|
+
phone.body.client.id,
|
|
1547
|
+
].sort());
|
|
1548
|
+
|
|
1549
|
+
const revoked = await request(app).delete(`/api/client-auth/clients/${phone.body.client.id}`);
|
|
1550
|
+
expect(revoked.status).toBe(200);
|
|
1551
|
+
expect(revoked.body.revoked).toBe(true);
|
|
1552
|
+
|
|
1553
|
+
const purged = await request(app).delete('/api/client-auth/clients');
|
|
1554
|
+
expect(purged.status).toBe(200);
|
|
1555
|
+
expect(purged.body.purged).toBeGreaterThanOrEqual(1);
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
it('restricts direct client-token creation to operator clients', async () => {
|
|
1359
1559
|
const app = express();
|
|
1360
1560
|
let authContext = { type: 'session' };
|
|
1361
1561
|
const dependencies = createDependencies({
|
|
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
|
|
19
19
|
## Entrypoints and structure
|
|
20
20
|
|
|
21
21
|
Host side (`packages/web/server/lib/relay/`):
|
|
22
|
-
- `service.js` — thin entrypoint: relay config (enabled flag +
|
|
22
|
+
- `service.js` — thin entrypoint: relay config (enabled flag + endpoints), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`, `POST /api/openchamber/relay/endpoints`), 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. **Multi-relay:** `settings.privateRelay = { enabled, relayUrl, extraRelayUrls }` — the host dials EVERY configured endpoint simultaneously with one identity (same serverId); clients treat `serverId + relayUrl` as the instance identity, so the same machine exposed via two domains appears as two separately named instances. The primary (`relayUrl`) owns push registration and stays the default pairing candidate. A pairing link created for an endpoint the host does not have yet APPENDS it as an extra endpoint (owner request only) instead of replacing the primary, so devices paired over the old domain keep working. `POST /relay/endpoints` (owner session / `desktop-local` only) replaces the extra-endpoint list wholesale and starts/stops host connections incrementally. Paired desktops may pair over any CONFIGURED endpoint but can never add one. 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`, `/relay/endpoints`, 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
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.
|
|
@@ -59,6 +59,7 @@ See also `packages/web/server/lib/opencode/DOCUMENTATION.md` (“Catalog / OpenC
|
|
|
59
59
|
## Authentication model
|
|
60
60
|
|
|
61
61
|
- The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
|
|
62
|
+
- A paired **desktop** client is a device-fleet operator: through the tunnel it can create pairing links, list every paired device, and revoke other devices (parity with the SSH attach flow, where the connecting desktop authenticates as the operator). Mobile (and any other) paired clients stay scoped to their own record. Re-pointing the Host relay endpoint set from a remote bearer remains forbidden — a paired desktop may name any endpoint the host already has, but adding/removing endpoints (including `relayUrl` in pairing requests for a new domain) is an owner-UI / local-desktop-shell action.
|
|
62
63
|
- Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
|
|
63
64
|
- The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
|
|
64
65
|
- Enabling the relay is explicit opt-in and disabled by default; disabling it severs all relay reachability immediately.
|