mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
* fix(web-shell): preserve session URL context * fix(web-shell): keep daemon token out of the session URL (#7926) Restore the daemon token stripping that was dropped alongside the base-path fix: removeDaemonTokenFromUrl() on startup and the ?token= delete in replaceStandaloneSessionUrl. The ?token= query path is still supported for backward compatibility, so without stripping it leaks into the address bar, history, access logs, and Referer headers. Also extract the session pathname building into buildSessionPathname() with unit tests covering root/sub-path deployments, the no-session case, trailing slashes, and id encoding. * fix(web-shell): anchor session URL parser to agree with writer (#7926) Extract parseSessionId() next to buildSessionPathname() and anchor it to the last /session/<id> segment so the parser agrees with the greedy writer. Previously a base path ending in a session segment produced /app/session/session/<id>, which the first-match parser read back as the literal id "session". Add round-trip and trailing-slash coverage. * test(web-shell): cover parseSessionId malformed-encoding catch branch (#7926) * fix(web-shell): preserve base path in split-view URL (#7926) --------- Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
145 lines
5 KiB
TypeScript
145 lines
5 KiB
TypeScript
export function getDaemonBaseUrl(): string {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
const raw = new URLSearchParams(window.location.search).get('daemon') || '';
|
|
if (!raw) return '';
|
|
return getAllowedDaemonOrigin(raw);
|
|
}
|
|
|
|
let cachedDaemonToken: string | undefined;
|
|
const DAEMON_AUTH_MESSAGE_TYPE = 'qwen-daemon-auth';
|
|
const DEFAULT_TOKEN_MESSAGE_TIMEOUT_MS = 2500;
|
|
const DAEMON_TOKEN_STORAGE_KEY = 'qwen-daemon-token';
|
|
|
|
// sessionStorage access can throw (privacy modes, storage-disabled
|
|
// embeds); the token flow must degrade to the pre-persistence behavior
|
|
// rather than break page load.
|
|
function readStoredDaemonToken(): string | undefined {
|
|
try {
|
|
return window.sessionStorage.getItem(DAEMON_TOKEN_STORAGE_KEY) || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function persistDaemonToken(token: string): void {
|
|
try {
|
|
window.sessionStorage.setItem(DAEMON_TOKEN_STORAGE_KEY, token);
|
|
} catch {
|
|
// Storage unavailable — the token still works for this load via the
|
|
// in-memory cache; a refresh will lose it, matching the old behavior.
|
|
}
|
|
}
|
|
|
|
export function getDaemonToken(): string | undefined {
|
|
if (cachedDaemonToken) return cachedDaemonToken;
|
|
if (typeof window === 'undefined') {
|
|
return undefined;
|
|
}
|
|
// Prefer the URL fragment (#token=) — unlike a ?token= query it is never
|
|
// sent to the server, so it stays out of access logs and Referer headers
|
|
// (this is what `qwen serve --open` now uses). Fall back to ?token= for
|
|
// backward compatibility (e.g. the dev launcher / hand-built URLs).
|
|
const fromHash = new URLSearchParams(
|
|
window.location.hash.replace(/^#/, ''),
|
|
).get('token');
|
|
const fromUrl =
|
|
fromHash || new URLSearchParams(window.location.search).get('token') || '';
|
|
if (fromUrl) {
|
|
// Persist per-tab so the token survives navigations that do not carry it.
|
|
// sessionStorage (not localStorage) keeps the token scoped to this tab and
|
|
// cleared when the tab closes.
|
|
persistDaemonToken(fromUrl);
|
|
cachedDaemonToken = fromUrl;
|
|
return cachedDaemonToken;
|
|
}
|
|
// Refresh path: the URL was already cleaned on the first load — fall
|
|
// back to the per-tab persisted copy.
|
|
cachedDaemonToken = readStoredDaemonToken();
|
|
return cachedDaemonToken;
|
|
}
|
|
|
|
export function waitForDaemonTokenMessage(
|
|
timeoutMs = DEFAULT_TOKEN_MESSAGE_TIMEOUT_MS,
|
|
): Promise<string | undefined> {
|
|
if (typeof window === 'undefined' || window.parent === window) {
|
|
return Promise.resolve(undefined);
|
|
}
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = (token: string | undefined): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
window.removeEventListener('message', onMessage);
|
|
clearTimeout(timer);
|
|
cachedDaemonToken = token;
|
|
resolve(token);
|
|
};
|
|
const onMessage = (event: MessageEvent): void => {
|
|
if (event.source !== window.parent) return;
|
|
if (
|
|
!event.origin.startsWith('chrome-extension://') &&
|
|
!event.origin.startsWith('moz-extension://')
|
|
) {
|
|
return;
|
|
}
|
|
const data = event.data as { type?: unknown; token?: unknown };
|
|
if (data?.type !== DAEMON_AUTH_MESSAGE_TYPE) return;
|
|
const token = typeof data.token === 'string' ? data.token : '';
|
|
finish(token.trim() || undefined);
|
|
};
|
|
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
window.addEventListener('message', onMessage);
|
|
});
|
|
}
|
|
|
|
export function removeDaemonTokenFromUrl(): void {
|
|
if (typeof window === 'undefined') return;
|
|
if (import.meta.env.DEV) return;
|
|
const url = new URL(window.location.href);
|
|
let changed = false;
|
|
if (url.searchParams.has('token')) {
|
|
url.searchParams.delete('token');
|
|
changed = true;
|
|
}
|
|
if (url.hash) {
|
|
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
|
|
if (hashParams.has('token')) {
|
|
hashParams.delete('token');
|
|
const rest = hashParams.toString();
|
|
url.hash = rest ? `#${rest}` : '';
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) window.history.replaceState(null, '', url);
|
|
}
|
|
|
|
export function getDaemonAuthHeaders(): HeadersInit | undefined {
|
|
const token = getDaemonToken();
|
|
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
|
}
|
|
|
|
function getAllowedDaemonOrigin(raw: string): string {
|
|
try {
|
|
const parsed = new URL(raw, window.location.origin);
|
|
const isHttp = parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
if (!isHttp) return '';
|
|
if (parsed.origin === window.location.origin) return parsed.origin;
|
|
const isLocalhost =
|
|
parsed.hostname === 'localhost' ||
|
|
parsed.hostname === '127.0.0.1' ||
|
|
parsed.hostname === '::1' ||
|
|
parsed.hostname === '[::1]';
|
|
if (!isLocalhost) return '';
|
|
const pagePort =
|
|
window.location.port ||
|
|
(window.location.protocol === 'https:' ? '443' : '80');
|
|
const daemonPort =
|
|
parsed.port || (parsed.protocol === 'https:' ? '443' : '80');
|
|
if (daemonPort !== pagePort) return '';
|
|
return parsed.origin;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|