Skip to content

Commit 5f26953

Browse files
committed
fix(web-ui): serve copied session links
1 parent d42ce7f commit 5f26953

4 files changed

Lines changed: 204 additions & 9 deletions

File tree

cli.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10461,6 +10461,7 @@ function assertRequestAuthorized(req, res) {
1046110461

1046210462
function isProtectedWebSurfacePath(requestPath) {
1046310463
return requestPath === '/'
10464+
|| requestPath === '/session'
1046410465
|| requestPath === '/web-ui/index.html'
1046510466
|| requestPath.startsWith('/web-ui/')
1046610467
|| requestPath.startsWith('/res/');
@@ -11889,8 +11890,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1188911890
});
1189011891
fs.createReadStream(filePath).pipe(res);
1189111892
} else {
11892-
// Only serve HTML for root path; /web-ui returns 404.
11893-
if (requestPath === '/') {
11893+
// Serve the SPA shell for routable entry points. Keep /web-ui as 404.
11894+
if (requestPath === '/' || requestPath === '/session') {
1189411895
try {
1189511896
const html = readBundledWebUiHtml(htmlPath);
1189611897
res.writeHead(200, {

tests/unit/web-run-host.test.mjs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,19 @@ const releaseRunPortIfNeededSource = extractFunctionBySignature(
135135
'function releaseRunPortIfNeeded(port, host, deps = {}) {',
136136
'releaseRunPortIfNeeded'
137137
);
138+
const isProtectedWebSurfacePathSource = extractFunctionBySignature(
139+
cliContent,
140+
'function isProtectedWebSurfacePath(requestPath) {',
141+
'isProtectedWebSurfacePath'
142+
);
138143
const resolveWebHost = instantiateFunction(resolveWebHostSource, 'resolveWebHost', {
139144
DEFAULT_WEB_HOST: defaultHostMatch[1],
140145
process: { env: {} }
141146
});
147+
const isProtectedWebSurfacePath = instantiateFunction(
148+
isProtectedWebSurfacePathSource,
149+
'isProtectedWebSurfacePath'
150+
);
142151

143152
test('resolveWebHost defaults to LAN host', () => {
144153
assert.strictEqual(resolveWebHost({}), '127.0.0.1');
@@ -891,6 +900,7 @@ function mockWriteJsonResponse(res, statusCode, payload, headers = {}) {
891900

892901
function mockIsProtectedWebSurfacePath(requestPath) {
893902
return requestPath === '/'
903+
|| requestPath === '/session'
894904
|| requestPath === '/web-ui/index.html'
895905
|| requestPath.startsWith('/web-ui/')
896906
|| requestPath.startsWith('/res/');
@@ -932,6 +942,11 @@ test('assertRequestAuthorized returns a basic auth challenge for unauthorized re
932942
assert.strictEqual(response.body, '{\n "error": "Unauthorized"\n}');
933943
});
934944

945+
test('isProtectedWebSurfacePath protects standalone session links', () => {
946+
assert.strictEqual(isProtectedWebSurfacePath('/session'), true);
947+
assert.strictEqual(isProtectedWebSurfacePath('/session/extra'), false);
948+
});
949+
935950
test('resolveSkillTarget still falls back to default target when target is omitted', () => {
936951
assert.deepStrictEqual(resolveSkillTarget({}), SKILL_TARGETS[0]);
937952
assert.deepStrictEqual(resolveSkillTarget({ items: [] }), SKILL_TARGETS[0]);
@@ -984,6 +999,44 @@ test('createWebServer redirects bundled index URL to the canonical root URL', ()
984999
assert.deepStrictEqual(errors, []);
9851000
});
9861001

1002+
test('createWebServer serves the SPA shell for standalone session links', () => {
1003+
const { requestHandler, errors } = createWebServerHarness({
1004+
htmlReader() {
1005+
return '<!doctype html><title>standalone session</title>';
1006+
}
1007+
});
1008+
const response = createMockResponse();
1009+
1010+
requestHandler({ url: '/session?source=codex&sessionId=session-1' }, response);
1011+
1012+
assert.strictEqual(response.statusCode, 200);
1013+
assert.strictEqual(response.body, '<!doctype html><title>standalone session</title>');
1014+
assert.deepStrictEqual(errors, []);
1015+
});
1016+
1017+
test('createWebServer requires auth before serving standalone session links to remote clients', () => {
1018+
const calls = [];
1019+
const { requestHandler, errors } = createWebServerHarness({
1020+
htmlReader() {
1021+
throw new Error('html should not be read before auth');
1022+
},
1023+
authorizeRequest(req, res) {
1024+
calls.push(req.url);
1025+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
1026+
res.end('{"error":"Unauthorized"}');
1027+
return { ok: false, mode: 'unauthorized' };
1028+
}
1029+
});
1030+
const response = createMockResponse();
1031+
1032+
requestHandler({ url: '/session?source=codex&sessionId=session-1', socket: { remoteAddress: '192.0.2.10' } }, response);
1033+
1034+
assert.strictEqual(response.statusCode, 401);
1035+
assert.deepStrictEqual(calls, ['/session?source=codex&sessionId=session-1']);
1036+
assert.strictEqual(response.body, '{"error":"Unauthorized"}');
1037+
assert.deepStrictEqual(errors, []);
1038+
});
1039+
9871040
test('createWebServer requires auth before serving root page to remote clients', () => {
9881041
const calls = [];
9891042
const { requestHandler, errors } = createWebServerHarness({

tests/unit/web-ui-startup-init.test.mjs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,148 @@ test('mounted skips auxiliary startup requests when loadAll fails', async () =>
203203
assert.strictEqual(syncDefaultOpenclawCalls, 0);
204204
});
205205
});
206+
207+
function createStartupMountContext(overrides = {}) {
208+
return {
209+
mainTab: 'dashboard',
210+
configMode: 'codex',
211+
settingsTab: 'general',
212+
sessionResumeWithYolo: true,
213+
claudeConfigs: {},
214+
openclawConfigs: {
215+
'默认配置': {
216+
content: ''
217+
}
218+
},
219+
currentOpenclawConfig: '',
220+
switchedTabs: [],
221+
initSessionStandaloneCalls: [],
222+
initSessionStandalone() {
223+
this.initSessionStandaloneCalls.push(window.location.href);
224+
},
225+
switchMainTab(tab) {
226+
this.switchedTabs.push(tab);
227+
this.mainTab = tab;
228+
},
229+
updateCompactLayoutMode() {},
230+
restoreSessionFilterCache() {},
231+
restoreSessionPinnedMap() {},
232+
normalizeShareCommandPrefix(value) {
233+
return value || 'npm start';
234+
},
235+
normalizeSessionTrashEnabled(value) {
236+
return value !== '0' && value !== 'false';
237+
},
238+
normalizeSessionTrashRetentionDays(value) {
239+
const numeric = Number(value);
240+
if (!Number.isFinite(numeric) || numeric < 1) return 30;
241+
return Math.min(365, Math.max(1, Math.floor(numeric)));
242+
},
243+
onWindowResize() {},
244+
handleGlobalKeydown() {},
245+
handleBeforeUnload() {},
246+
refreshClaudeSelectionFromSettings() {
247+
return Promise.resolve();
248+
},
249+
syncDefaultOpenclawConfigEntry() {
250+
return Promise.resolve();
251+
},
252+
loadAll() {
253+
return Promise.resolve(true);
254+
},
255+
...overrides
256+
};
257+
}
258+
259+
function createMutableWindowLocation(initialHref) {
260+
const location = {
261+
href: '',
262+
origin: '',
263+
pathname: '',
264+
search: '',
265+
hash: '',
266+
replace(nextHref) {
267+
apply(nextHref);
268+
}
269+
};
270+
function apply(nextHref) {
271+
const url = new URL(nextHref, location.href || initialHref);
272+
location.href = url.href;
273+
location.origin = url.origin;
274+
location.pathname = url.pathname;
275+
location.search = url.search;
276+
location.hash = url.hash;
277+
}
278+
apply(initialHref);
279+
return { location, apply };
280+
}
281+
282+
function createStartupMountGlobals(initialHref, replacements = []) {
283+
const mutable = createMutableWindowLocation(initialHref);
284+
return {
285+
document: {
286+
readyState: 'complete'
287+
},
288+
localStorage: {
289+
getItem() {
290+
return null;
291+
},
292+
setItem() {},
293+
removeItem() {}
294+
},
295+
window: {
296+
location: mutable.location,
297+
history: {
298+
replaceState(_state, _title, nextHref) {
299+
replacements.push(String(nextHref));
300+
mutable.apply(nextHref);
301+
}
302+
},
303+
addEventListener() {},
304+
removeEventListener() {}
305+
},
306+
requestAnimationFrame(callback) {
307+
return callback;
308+
},
309+
cancelAnimationFrame() {},
310+
setTimeout(callback) {
311+
return callback;
312+
},
313+
clearTimeout() {}
314+
};
315+
}
316+
317+
test('mounted preserves standalone session query parameters before standalone initialization', async () => {
318+
const appOptions = await captureCurrentBundledAppOptions();
319+
const context = createStartupMountContext();
320+
const replacements = [];
321+
322+
await withGlobalOverrides(
323+
createStartupMountGlobals('http://127.0.0.1:3737/session?source=codex&sessionId=pr187-browser-link', replacements),
324+
async () => {
325+
appOptions.mounted.call(context);
326+
}
327+
);
328+
329+
assert.deepStrictEqual(replacements, []);
330+
assert.deepStrictEqual(context.initSessionStandaloneCalls, [
331+
'http://127.0.0.1:3737/session?source=codex&sessionId=pr187-browser-link'
332+
]);
333+
});
334+
335+
test('mounted consumes shareable sessions tab URLs before canonical cleanup', async () => {
336+
const appOptions = await captureCurrentBundledAppOptions();
337+
const context = createStartupMountContext();
338+
const replacements = [];
339+
340+
await withGlobalOverrides(
341+
createStartupMountGlobals('http://127.0.0.1:3737/?tab=sessions', replacements),
342+
async () => {
343+
appOptions.mounted.call(context);
344+
}
345+
);
346+
347+
assert.deepStrictEqual(replacements, []);
348+
assert.deepStrictEqual(context.switchedTabs, ['sessions']);
349+
assert.strictEqual(context.mainTab, 'sessions');
350+
});

web-ui/app.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -459,13 +459,9 @@ document.addEventListener('DOMContentLoaded', () => {
459459
window.location.replace(url.toString());
460460
return;
461461
}
462-
// 清理任何查询参数和 hash,保持 URL 为 /
463-
if (window.location.search || window.location.hash) {
464-
const url = new URL(window.location.href);
465-
url.search = '';
466-
url.hash = '';
467-
window.history.replaceState(null, '', url.toString());
468-
}
462+
// Do not strip query/hash during startup: /session uses them to identify the
463+
// standalone session, and shareable tab/filter URLs are consumed below before
464+
// later runtime canonicalization can clean the address bar.
469465
} catch (_) {}
470466

471467
if (typeof this.initI18n === 'function') {

0 commit comments

Comments
 (0)