From cfff0857f7e71a4d668d7c4b6084969e4bd0efc1 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Wed, 3 Jun 2026 09:05:58 +0000 Subject: [PATCH 1/4] fix: require auth for remote web access --- cli.js | 38 ++++++++- tests/unit/web-run-host.test.mjs | 138 ++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/cli.js b/cli.js index 14f17ad1..da1533b7 100644 --- a/cli.js +++ b/cli.js @@ -10412,8 +10412,20 @@ function extractRequestToken(req) { const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {}; const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : ''; if (rawAuth) { - const match = rawAuth.match(/^bearer\s+(.+)$/i); - if (match && match[1]) return match[1].trim(); + const bearerMatch = rawAuth.match(/^bearer\s+(.+)$/i); + if (bearerMatch && bearerMatch[1]) return bearerMatch[1].trim(); + const basicMatch = rawAuth.match(/^basic\s+(.+)$/i); + if (basicMatch && basicMatch[1]) { + try { + const decoded = Buffer.from(basicMatch[1].trim(), 'base64').toString('utf-8'); + const separatorIndex = decoded.indexOf(':'); + if (separatorIndex >= 0) { + const password = decoded.slice(separatorIndex + 1).trim(); + if (password) return password; + } + if (decoded.trim()) return decoded.trim(); + } catch (_) { } + } return rawAuth; } const raw = typeof headers['x-codexmate-token'] === 'string' ? headers['x-codexmate-token'].trim() : ''; @@ -10445,6 +10457,13 @@ function assertRequestAuthorized(req, res) { return { ok: true, mode: 'token' }; } +function isProtectedWebSurfacePath(requestPath) { + return requestPath === '/' + || requestPath === '/web-ui/index.html' + || requestPath.startsWith('/web-ui/') + || requestPath.startsWith('/res/'); +} + const g_webhookDeliveryCache = new Map(); function pruneWebhookDeliveryCache() { @@ -10942,6 +10961,21 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser if (typeof openaiBridgeHandler === 'function' && openaiBridgeHandler(req, res)) { return; } + if (isProtectedWebSurfacePath(requestPath)) { + const remoteAddr = req && req.socket ? req.socket.remoteAddress : ''; + const isLoopback = !remoteAddr || isLoopbackRemoteAddress(remoteAddr); + if (!isLoopback) { + const rateLimitKey = (remoteAddr || 'unknown') + ':' + requestPath; + if (!checkRateLimit(rateLimitKey)) { + writeJsonResponse(res, 429, { error: 'Rate limit exceeded' }, { 'Retry-After': '60' }); + return; + } + const auth = assertRequestAuthorized(req, res); + if (!auth.ok) { + return; + } + } + } if ( requestPath === '/api' || requestPath.startsWith('/api/import-') diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index 697bd7aa..03e911f6 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -720,6 +720,12 @@ const handleImportSkillsZipUploadSource = extractFunctionBySignature( 'async function handleImportSkillsZipUpload(req, res, options = {}) {', 'handleImportSkillsZipUpload' ); +const extractRequestTokenSource = extractFunctionBySignature( + cliContent, + 'function extractRequestToken(req) {', + 'extractRequestToken' +); +const extractRequestToken = instantiateFunction(extractRequestTokenSource, 'extractRequestToken', { Buffer }); const createWebServerSource = extractFunctionBySignature( cliContent, 'function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {', @@ -750,7 +756,8 @@ function createMockResponse() { function createWebServerHarness({ htmlReader = () => '', - dynamicAssets = new Map() + dynamicAssets = new Map(), + authorizeRequest = () => ({ ok: true }) } = {}) { let requestHandler = null; const errors = []; @@ -803,6 +810,24 @@ function createWebServerHarness({ isAnyAddressHost() { return false; }, + isLoopbackRemoteAddress(value) { + return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; + }, + assertRequestAuthorized: authorizeRequest, + writeJsonResponse(res, statusCode, payload, headers = {}) { + const body = JSON.stringify(payload || {}, null, 2); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + ...headers + }); + res.end(body); + }, + isProtectedWebSurfacePath(requestPath) { + return requestPath === '/' + || requestPath === '/web-ui/index.html' + || requestPath.startsWith('/web-ui/') + || requestPath.startsWith('/res/'); + }, process: { env: {} }, exec() {}, console: { @@ -860,6 +885,37 @@ function assertNotFoundResponse(response) { assert.strictEqual(response.destroyedWith, null); } +function mockIsLoopbackRemoteAddress(value) { + return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; +} + +function mockWriteJsonResponse(res, statusCode, payload, headers = {}) { + const body = JSON.stringify(payload || {}, null, 2); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + ...headers + }); + res.end(body); +} + +function mockIsProtectedWebSurfacePath(requestPath) { + return requestPath === '/' + || requestPath === '/web-ui/index.html' + || requestPath.startsWith('/web-ui/') + || requestPath.startsWith('/res/'); +} + +function mockAssertRequestAuthorized() { + return { ok: true }; +} + +test('extractRequestToken accepts bearer, custom header, and browser-friendly basic auth', () => { + assert.strictEqual(extractRequestToken({ headers: { authorization: 'Bearer web-token' } }), 'web-token'); + assert.strictEqual(extractRequestToken({ headers: { 'x-codexmate-token': ' header-token ' } }), 'header-token'); + assert.strictEqual(extractRequestToken({ headers: { authorization: `Basic ${Buffer.from(':basic-token').toString('base64')}` } }), 'basic-token'); + assert.strictEqual(extractRequestToken({ headers: { authorization: `Basic ${Buffer.from('codexmate:basic-token').toString('base64')}` } }), 'basic-token'); +}); + test('resolveSkillTarget still falls back to default target when target is omitted', () => { assert.deepStrictEqual(resolveSkillTarget({}), SKILL_TARGETS[0]); assert.deepStrictEqual(resolveSkillTarget({ items: [] }), SKILL_TARGETS[0]); @@ -912,6 +968,70 @@ test('createWebServer redirects bundled index URL to the canonical root URL', () assert.deepStrictEqual(errors, []); }); +test('createWebServer requires auth before serving root page to remote clients', () => { + const calls = []; + const { requestHandler, errors } = createWebServerHarness({ + htmlReader() { + throw new Error('html should not be read before auth'); + }, + authorizeRequest(req, res) { + calls.push(req.url); + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end('{"error":"Unauthorized"}'); + return { ok: false, mode: 'unauthorized' }; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/', socket: { remoteAddress: '192.0.2.10' } }, response); + + assert.strictEqual(response.statusCode, 401); + assert.deepStrictEqual(calls, ['/']); + assert.strictEqual(response.body, '{"error":"Unauthorized"}'); + assert.deepStrictEqual(errors, []); +}); + +test('createWebServer requires auth before serving web-ui assets to remote clients', () => { + const calls = []; + const { requestHandler, errors } = createWebServerHarness({ + authorizeRequest(req, res) { + calls.push(req.url); + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end('{"error":"Unauthorized"}'); + return { ok: false, mode: 'unauthorized' }; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/web-ui/app.js', socket: { remoteAddress: '192.0.2.10' } }, response); + + assert.strictEqual(response.statusCode, 401); + assert.deepStrictEqual(calls, ['/web-ui/app.js']); + assert.strictEqual(response.body, '{"error":"Unauthorized"}'); + assert.deepStrictEqual(errors, []); +}); + +test('createWebServer keeps loopback root page access unchanged', () => { + let authCalled = false; + const { requestHandler, errors } = createWebServerHarness({ + htmlReader() { + return 'codexmate'; + }, + authorizeRequest() { + authCalled = true; + return { ok: false }; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/', socket: { remoteAddress: '127.0.0.1' } }, response); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, 'codexmate'); + assert.strictEqual(authCalled, false); + assert.deepStrictEqual(errors, []); +}); + test('createWebServer returns 500 when fallback bundled html generation throws', () => { const { requestHandler, errors } = createWebServerHarness({ htmlReader() { @@ -984,6 +1104,10 @@ test('createWebServer prints port override guidance for EACCES listen errors', ( isAnyAddressHost() { return false; }, + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, + assertRequestAuthorized: mockAssertRequestAuthorized, + writeJsonResponse: mockWriteJsonResponse, + isProtectedWebSurfacePath: mockIsProtectedWebSurfacePath, process: { env: {}, platform: 'linux', @@ -1101,6 +1225,10 @@ test('createWebServer waits for api readiness probe before auto-opening the brow isAnyAddressHost() { return false; }, + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, + assertRequestAuthorized: mockAssertRequestAuthorized, + writeJsonResponse: mockWriteJsonResponse, + isProtectedWebSurfacePath: mockIsProtectedWebSurfacePath, process: { env: {}, platform: 'win32' }, spawn(command, args, options) { spawnCalls.push({ command, args, options, unrefCalled: false }); @@ -1235,6 +1363,10 @@ test('createWebServer health-check does not consume init notice before the first isAnyAddressHost() { return false; }, + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, + assertRequestAuthorized: mockAssertRequestAuthorized, + writeJsonResponse: mockWriteJsonResponse, + isProtectedWebSurfacePath: mockIsProtectedWebSurfacePath, process: { env: {}, platform: 'linux' }, exec() {}, console: { @@ -1413,6 +1545,10 @@ test('createWebServer retries readiness probe failures before auto-opening the b isAnyAddressHost() { return false; }, + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, + assertRequestAuthorized: mockAssertRequestAuthorized, + writeJsonResponse: mockWriteJsonResponse, + isProtectedWebSurfacePath: mockIsProtectedWebSurfacePath, process: { env: {}, platform: 'linux' }, spawn(command, args, options) { spawnCalls.push({ command, args, options, unrefCalled: false }); From 3d24489563f4d91130492fb0dda85b5a31f66e20 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Wed, 3 Jun 2026 09:25:53 +0000 Subject: [PATCH 2/4] feat: copy session file paths from browser --- tests/e2e/test-sessions.js | 11 ++++ .../unit/session-actions-standalone.test.mjs | 55 +++++++++++++++++++ tests/unit/web-ui-behavior-parity.test.mjs | 4 +- .../modules/app.methods.session-actions.mjs | 26 +++++++++ web-ui/modules/i18n/locales/en.mjs | 1 + web-ui/modules/i18n/locales/ja.mjs | 2 + web-ui/modules/i18n/locales/zh.mjs | 1 + web-ui/partials/index/panel-sessions.html | 6 ++ web-ui/res/web-ui-render.precompiled.js | 7 ++- 9 files changed, 111 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test-sessions.js b/tests/e2e/test-sessions.js index 7eb208cb..76db60dd 100644 --- a/tests/e2e/test-sessions.js +++ b/tests/e2e/test-sessions.js @@ -18,16 +18,27 @@ module.exports = async function testSessions(ctx) { assert(Array.isArray(apiSessions.sessions), 'api sessions missing'); assert(apiSessions.sessions.some(item => item.sessionId === sessionId), 'api sessions missing codex entry'); assert(typeof apiSessions.source === 'string', 'list-sessions missing source'); + const apiCodexEntry = apiSessions.sessions.find(item => item.sessionId === sessionId); + assert(apiCodexEntry && path.isAbsolute(apiCodexEntry.filePath || ''), 'codex session filePath should be absolute for copy-path'); + assert(apiCodexEntry && fs.existsSync(apiCodexEntry.filePath), 'codex session filePath should point to an existing file'); + assert(fs.statSync(apiCodexEntry.filePath).isFile(), 'codex session filePath should point to a file'); + assert(fs.readFileSync(apiCodexEntry.filePath, 'utf8').includes(sessionId), 'codex copied filePath should be readable session content'); // ========== List Sessions Tests - Claude ========== const apiSessionsClaude = await api('list-sessions', { source: 'claude', limit: 50, forceRefresh: true }); assert(Array.isArray(apiSessionsClaude.sessions), 'api sessions(claude) missing'); assert(apiSessionsClaude.sessions.some(item => item.sessionId === claudeSessionId), 'api sessions(claude) missing claude entry'); + const apiClaudeEntry = apiSessionsClaude.sessions.find(item => item.sessionId === claudeSessionId); + assert(apiClaudeEntry && path.isAbsolute(apiClaudeEntry.filePath || ''), 'claude session filePath should be absolute for copy-path'); + assert(apiClaudeEntry && fs.existsSync(apiClaudeEntry.filePath), 'claude session filePath should point to an existing file'); // ========== List Sessions Tests - Gemini ========== const apiSessionsGemini = await api('list-sessions', { source: 'gemini', limit: 50, forceRefresh: true }); assert(Array.isArray(apiSessionsGemini.sessions), 'api sessions(gemini) missing'); assert(apiSessionsGemini.sessions.some(item => item.sessionId === geminiSessionId), 'api sessions(gemini) missing gemini entry'); + const apiGeminiEntry = apiSessionsGemini.sessions.find(item => item.sessionId === geminiSessionId); + assert(apiGeminiEntry && path.isAbsolute(apiGeminiEntry.filePath || ''), 'gemini session filePath should be absolute for copy-path'); + assert(apiGeminiEntry && fs.existsSync(apiGeminiEntry.filePath), 'gemini session filePath should point to an existing file'); // ========== List Sessions Tests - All Sources ========== const apiSessionsAll = await api('list-sessions', { source: 'all', limit: 50, forceRefresh: true }); diff --git a/tests/unit/session-actions-standalone.test.mjs b/tests/unit/session-actions-standalone.test.mjs index b100da2b..7fc8d1de 100644 --- a/tests/unit/session-actions-standalone.test.mjs +++ b/tests/unit/session-actions-standalone.test.mjs @@ -59,3 +59,58 @@ test('copySessionLink shows an error when url cannot be built', async () => { type: 'error' }]); }); + +test('copySessionPath copies the session file path', async () => { + const methods = createSessionActionMethods({ apiBase: '' }); + const copied = []; + const context = { + ...methods, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + fallbackCopyText(value) { + copied.push(value); + return true; + } + }; + + await methods.copySessionPath.call(context, { + source: 'codex', + sessionId: 'session-1', + filePath: ' /tmp/codexmate/session-1.jsonl ' + }); + + assert.deepStrictEqual(copied, ['/tmp/codexmate/session-1.jsonl']); + assert.deepStrictEqual(context.shownMessages, [{ + message: '已复制路径', + type: 'success' + }]); +}); + +test('copySessionPath reports an error when the session has no file path', async () => { + const methods = createSessionActionMethods({ apiBase: '' }); + let copied = false; + const context = { + ...methods, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + fallbackCopyText() { + copied = true; + return true; + } + }; + + await methods.copySessionPath.call(context, { + source: 'codex', + sessionId: 'session-1' + }); + + assert.strictEqual(copied, false); + assert.deepStrictEqual(context.shownMessages, [{ + message: '无本地文件路径', + type: 'error' + }]); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 1afe6e8f..4e3b1598 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -645,7 +645,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'prevAccordionStep', 'finishAccordionStep', 'validateProviderName', - 'validateModelId' + 'validateModelId', + 'getSessionFilePath', + 'copySessionPath' ); const allowedMissingCurrentMethodKeys = [ 'convertSession', diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs index 3ef8a246..31edaea7 100644 --- a/web-ui/modules/app.methods.session-actions.mjs +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -129,6 +129,32 @@ export function createSessionActionMethods(options = {}) { this.showMessage('复制失败', 'error'); }, + getSessionFilePath(session) { + const filePath = typeof session?.filePath === 'string' ? session.filePath.trim() : ''; + return filePath; + }, + + async copySessionPath(session) { + const filePath = this.getSessionFilePath(session); + if (!filePath) { + this.showMessage('无本地文件路径', 'error'); + return; + } + const ok = this.fallbackCopyText(filePath); + if (ok) { + this.showMessage('已复制路径', 'success'); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(filePath); + this.showMessage('已复制路径', 'success'); + return; + } + } catch (_) {} + this.showMessage('复制失败', 'error'); + }, + getSessionExportKey(session) { return `${session.source || 'unknown'}:${session.sessionId || ''}:${session.filePath || ''}`; }, diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index d8f005ec..86b9baab 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -566,6 +566,7 @@ const en = Object.freeze({ 'sessions.preview.importNative.failed': 'Import failed', 'sessions.preview.importNative.failedWithReason': 'Import to native failed: {reason}', 'sessions.preview.copyLink': 'Copy link', + 'sessions.preview.copyPath': 'Copy path', 'sessions.preview.loadingBody': 'Loading session content...', 'sessions.preview.emptyMsgs': 'No messages to display', 'sessions.preview.rendering': 'Rendering session content...', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index a4ffa38c..376af5ed 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -554,6 +554,8 @@ const ja = Object.freeze({ 'sessions.preview.converting': '変換中...', 'sessions.preview.convert.loadedOnly': '読み込み済みのみ変換', 'sessions.preview.openStandalone': 'スタンドアロンで開く', + 'sessions.preview.copyLink': 'リンクをコピー', + 'sessions.preview.copyPath': 'パスをコピー', 'sessions.preview.loadingBody': 'メッセージ読み込み中...', 'sessions.preview.emptyMsgs': 'メッセージがありません', 'sessions.preview.rendering': 'レンダリング中...', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index 8a22397b..7b17aca3 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -565,6 +565,7 @@ const zh = Object.freeze({ 'sessions.preview.importNative.failed': '导入失败', 'sessions.preview.importNative.failedWithReason': '导入原生目录失败:{reason}', 'sessions.preview.copyLink': '复制链接', + 'sessions.preview.copyPath': '复制路径', 'sessions.preview.loadingBody': '正在加载会话内容...', 'sessions.preview.emptyMsgs': '当前会话暂无可展示消息', 'sessions.preview.rendering': '正在渲染会话内容...', diff --git a/web-ui/partials/index/panel-sessions.html b/web-ui/partials/index/panel-sessions.html index 4bc61246..aa6f29d9 100644 --- a/web-ui/partials/index/panel-sessions.html +++ b/web-ui/partials/index/panel-sessions.html @@ -241,6 +241,12 @@ :disabled="!activeSession"> {{ t('sessions.preview.copyLink') }} + diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index 843c1b71..4f122444 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -2604,7 +2604,12 @@ return function render(_ctx, _cache) { class: "btn-session-open", onClick: $event => (_ctx.copySessionLink(_ctx.activeSession)), disabled: !_ctx.activeSession - }, _toDisplayString(_ctx.t('sessions.preview.copyLink')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]) + }, _toDisplayString(_ctx.t('sessions.preview.copyLink')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + _createElementVNode("button", { + class: "btn-session-open", + onClick: $event => (_ctx.copySessionPath(_ctx.activeSession)), + disabled: !_ctx.activeSession || !_ctx.getSessionFilePath(_ctx.activeSession) + }, _toDisplayString(_ctx.t('sessions.preview.copyPath')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]) ]) ], 512 /* NEED_PATCH */), (_ctx.sessionDetailLoading && !_ctx.sessionPreviewLoadingMore) From 184e6d8cd962db787e376dcf4284d84316eff336 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Wed, 3 Jun 2026 09:29:25 +0000 Subject: [PATCH 3/4] fix: challenge unauthorized web clients --- cli.js | 4 +- tests/unit/web-run-host.test.mjs | 71 ++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/cli.js b/cli.js index da1533b7..ab58bf2b 100644 --- a/cli.js +++ b/cli.js @@ -10451,7 +10451,9 @@ function assertRequestAuthorized(req, res) { } const actual = extractRequestToken(req); if (!actual || !safeTimingEqual(actual, expected)) { - writeJsonResponse(res, 401, { error: 'Unauthorized' }); + writeJsonResponse(res, 401, { error: 'Unauthorized' }, { + 'WWW-Authenticate': 'Basic realm="codexmate"' + }); return { ok: false, mode: 'unauthorized' }; } return { ok: true, mode: 'token' }; diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index 03e911f6..5d831a6c 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -726,6 +726,11 @@ const extractRequestTokenSource = extractFunctionBySignature( 'extractRequestToken' ); const extractRequestToken = instantiateFunction(extractRequestTokenSource, 'extractRequestToken', { Buffer }); +const assertRequestAuthorizedSource = extractFunctionBySignature( + cliContent, + 'function assertRequestAuthorized(req, res) {', + 'assertRequestAuthorized' +); const createWebServerSource = extractFunctionBySignature( cliContent, 'function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {', @@ -810,24 +815,10 @@ function createWebServerHarness({ isAnyAddressHost() { return false; }, - isLoopbackRemoteAddress(value) { - return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; - }, + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, assertRequestAuthorized: authorizeRequest, - writeJsonResponse(res, statusCode, payload, headers = {}) { - const body = JSON.stringify(payload || {}, null, 2); - res.writeHead(statusCode, { - 'Content-Type': 'application/json; charset=utf-8', - ...headers - }); - res.end(body); - }, - isProtectedWebSurfacePath(requestPath) { - return requestPath === '/' - || requestPath === '/web-ui/index.html' - || requestPath.startsWith('/web-ui/') - || requestPath.startsWith('/res/'); - }, + writeJsonResponse: mockWriteJsonResponse, + isProtectedWebSurfacePath: mockIsProtectedWebSurfacePath, process: { env: {} }, exec() {}, console: { @@ -916,6 +907,31 @@ test('extractRequestToken accepts bearer, custom header, and browser-friendly ba assert.strictEqual(extractRequestToken({ headers: { authorization: `Basic ${Buffer.from('codexmate:basic-token').toString('base64')}` } }), 'basic-token'); }); +test('assertRequestAuthorized returns a basic auth challenge for unauthorized remote clients', () => { + const assertRequestAuthorized = instantiateFunction(assertRequestAuthorizedSource, 'assertRequestAuthorized', { + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, + readServerToken() { + return 'expected-token'; + }, + extractRequestToken, + safeTimingEqual(actual, expected) { + return actual === expected; + }, + writeJsonResponse: mockWriteJsonResponse + }); + const response = createMockResponse(); + + const result = assertRequestAuthorized({ + headers: {}, + socket: { remoteAddress: '192.0.2.10' } + }, response); + + assert.deepStrictEqual(result, { ok: false, mode: 'unauthorized' }); + assert.strictEqual(response.statusCode, 401); + assert.strictEqual(response.headers['WWW-Authenticate'], 'Basic realm="codexmate"'); + assert.strictEqual(response.body, '{\n "error": "Unauthorized"\n}'); +}); + test('resolveSkillTarget still falls back to default target when target is omitted', () => { assert.deepStrictEqual(resolveSkillTarget({}), SKILL_TARGETS[0]); assert.deepStrictEqual(resolveSkillTarget({ items: [] }), SKILL_TARGETS[0]); @@ -1011,6 +1027,27 @@ test('createWebServer requires auth before serving web-ui assets to remote clien assert.deepStrictEqual(errors, []); }); +test('createWebServer serves root page to authorized remote clients', () => { + const calls = []; + const { requestHandler, errors } = createWebServerHarness({ + htmlReader() { + return 'authorized remote'; + }, + authorizeRequest(req) { + calls.push(req.url); + return { ok: true, mode: 'token' }; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/', socket: { remoteAddress: '192.0.2.10' } }, response); + + assert.strictEqual(response.statusCode, 200); + assert.deepStrictEqual(calls, ['/']); + assert.strictEqual(response.body, 'authorized remote'); + assert.deepStrictEqual(errors, []); +}); + test('createWebServer keeps loopback root page access unchanged', () => { let authCalled = false; const { requestHandler, errors } = createWebServerHarness({ From 913853b98b7af83bff1ac1e3a77cb65f4d0e9e28 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Wed, 3 Jun 2026 10:01:48 +0000 Subject: [PATCH 4/4] chore: bump version to 0.0.42 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49827900..4d862295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexmate", - "version": "0.0.41", + "version": "0.0.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.0.41", + "version": "0.0.42", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index c09e784f..a393e822 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexmate", - "version": "0.0.41", + "version": "0.0.42", "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具", "main": "cli.js", "bin": {