diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index df5fb31..baf92a5 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 51ea4ec..f955b7f 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -82,7 +82,7 @@ function createAgentRuntime(deps) { const config = mcpServer?.config && typeof mcpServer.config === 'object' ? mcpServer.config : null; if (!server || !config) return; const prefix = `mcp_servers.${tomlKeySegment(server)}`; - for (const key of ['type', 'command', 'args', 'env', 'env_vars', 'url', 'startup_timeout_sec', 'tool_timeout_sec']) { + for (const key of ['type', 'command', 'args', 'env', 'env_vars', 'url', 'bearer_token_env_var', 'startup_timeout_sec', 'tool_timeout_sec']) { if (!Object.prototype.hasOwnProperty.call(config, key)) continue; args.push('-c', `${prefix}.${key}=${tomlValue(config[key])}`); } diff --git a/public/app.js b/public/app.js index bff58a9..63decb1 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260626-ccweb-prompt-compact-ui'; + const ASSET_VERSION = '20260629-ccweb-prompt-dark-theme'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; diff --git a/public/index.html b/public/index.html index 6e17a21..8cb545a 100644 --- a/public/index.html +++ b/public/index.html @@ -20,7 +20,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -173,6 +173,6 @@ - + diff --git a/public/style.css b/public/style.css index ce1cac1..d080b7c 100644 --- a/public/style.css +++ b/public/style.css @@ -6254,3 +6254,128 @@ html[data-theme='coolvibe'] .settings-back:hover { :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .theme-card-swatch { border-color: rgba(255, 255, 255, 0.16); } + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-card { + background: linear-gradient(180deg, var(--dark-panel-bg), var(--bg-bubble-assistant)); + border-color: var(--border-color); + border-left-color: var(--accent); + color: var(--text-primary); + box-shadow: var(--shadow-strong); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-card.ccweb-prompt-focus { + animation: ccwebPromptFocusDark 1.4s ease; +} + +@keyframes ccwebPromptFocusDark { + 0% { + box-shadow: 0 0 0 0 var(--accent-light), var(--shadow-strong); + } + 45% { + box-shadow: 0 0 0 5px var(--accent-light), var(--shadow-strong); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0), var(--shadow-strong); + } +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-header, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-footer { + background: rgba(0, 0, 0, 0.12); + border-color: var(--border-color); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-status, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-required, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option-badge { + background: var(--accent-light); + border-color: var(--theme-card-hover-border); + color: var(--accent); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-card[data-status='submitted'] .ccweb-prompt-status { + background: rgba(103, 201, 135, 0.14); + border-color: rgba(103, 201, 135, 0.32); + color: var(--success); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-view-switcher, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab-nav-btn, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-question, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-answer, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-selected-readonly, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-answer-readonly, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-secondary, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-action { + background: var(--dark-panel-soft); + border-color: var(--border-color); + color: var(--text-primary); + box-shadow: none; +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-question { + background: rgba(255, 255, 255, 0.035); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option::before { + background: rgba(0, 0, 0, 0.18); + border-color: var(--text-muted); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option:hover, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab-nav-btn:hover, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-secondary:hover, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-action:hover { + background: var(--accent-light); + border-color: var(--accent); + color: var(--text-primary); + box-shadow: none; +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab.is-active, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option.is-selected { + background: var(--accent-light); + border-color: var(--accent); + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--theme-card-hover-border); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option.is-selected::before { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-answer { + background: rgba(0, 0, 0, 0.18); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-answer::placeholder { + color: var(--text-muted); + opacity: 0.86; +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-answer:focus { + background: var(--bg-bubble-assistant); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-submit:hover:not(:disabled) { + box-shadow: 0 8px 18px var(--accent-light); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-submit:disabled, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-secondary:disabled { + opacity: 0.56; +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-badge { + box-shadow: 0 0 0 3px var(--accent-light); +} + +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-dismiss:hover { + background: var(--accent-light); +} diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 2e09f15..62b3fd8 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -425,12 +425,24 @@ function completeMcpToolTurn(thread, turnId) { const ccwebConfig = thread.config?.['mcp_servers.ccweb'] || null; const projectConfig = thread.config?.['mcp_servers.reg-app-project'] || null; const env = ccwebConfig?.env || {}; + let urlSourceSessionId = null; + let urlSourceHopCount = null; + try { + if (ccwebConfig?.url) { + const parsedUrl = new URL(ccwebConfig.url); + urlSourceSessionId = parsedUrl.searchParams.get('sourceSessionId') || null; + urlSourceHopCount = parsedUrl.searchParams.get('sourceHopCount') || null; + } + } catch {} const payload = { ok: true, - currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || null, - sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || null, + currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || urlSourceSessionId, + sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || urlSourceHopCount, hasCcwebMcpConfig: Boolean(ccwebConfig), hasProjectMcpConfig: Boolean(projectConfig), + ccwebType: ccwebConfig?.type || (ccwebConfig?.url ? 'streamable_http' : (ccwebConfig?.command ? 'stdio' : null)), + ccwebUrl: ccwebConfig?.url || null, + ccwebBearerTokenEnvVar: ccwebConfig?.bearer_token_env_var || null, ccwebCommand: ccwebConfig?.command || null, ccwebArgs: ccwebConfig?.args || null, }; diff --git a/scripts/regression.js b/scripts/regression.js index 93b8339..b6a2a57 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -1516,7 +1516,10 @@ async function main() { assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data'); assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config'); assert(/"hasProjectMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass project MCP config from session cwd'); - assert(/server\.js/.test(codexAppDynamicTool.result || '') && /--ccweb-mcp-server/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP config should launch through server.js in Node mode'); + assert(/"ccwebType": "streamable_http"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should default to shared streamable HTTP'); + assert(/"ccwebUrl": "http:\/\/127\.0\.0\.1:\d+\/api\/internal\/mcp\/stream\?/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should point to the shared cc-web HTTP endpoint'); + assert(/"ccwebBearerTokenEnvVar": "CC_WEB_CODEX_APP_MCP_TOKEN"/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should use bearer_token_env_var for the shared endpoint'); + assert(!/--ccweb-mcp-server/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP should not launch a per-thread stdio bridge by default'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-prompt-user-mcp', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' })); diff --git a/server.js b/server.js index dbb5b8b..46d1587 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client'); const { createCodexAppRuntime } = require('./lib/codex-app-runtime'); const { createCodexRolloutStore } = require('./lib/codex-rollouts'); const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server'); +const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' }; if (process.argv.includes('--ccweb-mcp-server')) { require('./lib/ccweb-mcp-server').runStdioServer(); @@ -68,6 +69,12 @@ function readPositiveIntEnv(name, fallback, options = {}) { return Math.max(min, Math.min(max, raw)); } +function normalizeCodexAppCcwebMcpTransport(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'stdio') return 'stdio'; + return 'streamable_http'; +} + const PORT = parseInt(process.env.PORT) || 8002; const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude'; const CODEX_PATH = process.env.CODEX_PATH || 'codex'; @@ -112,11 +119,14 @@ const MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CRE const CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS', 3, { min: 1, max: 10 }); const CODEX_TRANSIENT_RETRY_BASE_DELAY_MS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS', 2000, { min: 100, max: 60000 }); const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4000; +const CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV = 'CC_WEB_CODEX_APP_MCP_TOKEN'; +const CODEX_APP_CCWEB_MCP_TRANSPORT = normalizeCodexAppCcwebMcpTransport(process.env.CC_WEB_CODEX_APP_CCWEB_MCP_TRANSPORT); const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || '')); const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED; const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [ 'CC_WEB_MCP_URL', 'CC_WEB_MCP_TOKEN', + CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV, 'CC_WEB_SOURCE_SESSION_ID', 'CC_WEB_CROSS_HOP_COUNT', 'CODEX_THREAD_ID', @@ -974,6 +984,8 @@ function normalizeCodexMcpServerConfig(name, rawConfig, source) { const url = normalizeSkillMcpUrl(rawConfig.url || rawConfig.server_url || ''); if (!url) return null; config.url = url; + const bearerTokenEnvVar = String(rawConfig.bearer_token_env_var || rawConfig.bearerTokenEnvVar || '').trim(); + if (bearerTokenEnvVar) config.bearer_token_env_var = bearerTokenEnvVar; } for (const key of ['startup_timeout_sec', 'tool_timeout_sec']) { if (Number.isFinite(rawConfig[key])) config[key] = rawConfig[key]; @@ -2297,7 +2309,31 @@ function summarizeSkillForMention(skill, configuredMcpNames = new Set()) { } function buildCcwebMcpRuntimeConfig(session, options = {}) { - const env = codexAppCcwebMcpEnv(session, options); + if (!session?.id || !INTERNAL_MCP_TOKEN) return null; + const rawHopCount = Number.parseInt(String(options.mcpContext?.hopCount || 0), 10); + const hopCount = Number.isFinite(rawHopCount) ? Math.max(0, rawHopCount) : 0; + if (normalizeAgent(options.agent || session?.agent || 'codex') === 'codexapp' + && CODEX_APP_CCWEB_MCP_TRANSPORT !== 'stdio') { + const params = new URLSearchParams({ + sourceSessionId: session.id, + sourceHopCount: String(hopCount), + }); + return { + server: 'ccweb', + name: 'ccweb', + source: 'builtin', + type: 'streamable_http', + description: 'ccweb 内置共享 MCP server,可用于跨会话协作。', + config: { + type: 'streamable_http', + url: `http://127.0.0.1:${PORT}/api/internal/mcp/stream?${params.toString()}`, + bearer_token_env_var: CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV, + startup_timeout_sec: 10, + tool_timeout_sec: 60, + }, + }; + } + const env = codexAppCcwebMcpEnv(session, { ...options, mcpContext: { hopCount } }); if (!env) return null; const commandSpec = ccwebMcpServerCommandSpec(); return { @@ -5076,6 +5112,110 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) { } } +function mcpJsonRpcResult(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +function mcpJsonRpcError(id, code, message, data) { + const error = { code, message }; + if (data !== undefined) error.data = data; + return { jsonrpc: '2.0', id, error }; +} + +function mcpToolResponse(payload) { + const text = JSON.stringify(payload, null, 2); + return { + content: [{ type: 'text', text }], + structuredContent: payload, + isError: !payload?.ok, + }; +} + +function handleMcpJsonRpcMessage(message, context = {}) { + const hasId = Object.prototype.hasOwnProperty.call(message || {}, 'id'); + if (!hasId) return null; + const id = message.id; + const method = String(message?.method || ''); + try { + switch (method) { + case 'initialize': + return mcpJsonRpcResult(id, { + protocolVersion: message.params?.protocolVersion || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: CCWEB_MCP_SERVER_INFO, + }); + case 'ping': + return mcpJsonRpcResult(id, {}); + case 'tools/list': + return mcpJsonRpcResult(id, { tools: CCWEB_MCP_TOOLS }); + case 'tools/call': { + const name = String(message.params?.name || ''); + const args = message.params?.arguments || {}; + if (!CCWEB_MCP_TOOLS.some((tool) => tool.name === name)) { + return mcpJsonRpcResult(id, mcpToolResponse(mcpToolError('unknown_tool', `未知工具: ${name}`))); + } + const payload = callInternalMcpTool( + name, + args, + context.sourceSessionId || '', + context.sourceHopCount || 0, + ); + return mcpJsonRpcResult(id, mcpToolResponse(payload)); + } + case 'resources/list': + return mcpJsonRpcResult(id, { resources: [] }); + case 'prompts/list': + return mcpJsonRpcResult(id, { prompts: [] }); + default: + return mcpJsonRpcError(id, -32601, `Method not found: ${method}`); + } + } catch (err) { + return mcpJsonRpcError(id, -32603, err?.message || 'Internal error'); + } +} + +async function handleSharedMcpHttpApi(req, res, url) { + if (req.method !== 'POST') { + res.writeHead(405, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-cache', + Allow: 'POST', + }); + res.end(JSON.stringify(mcpJsonRpcError(null, -32600, 'Only POST is supported.'))); + return; + } + + const token = getInternalMcpRequestToken(req); + if (!token || token !== INTERNAL_MCP_TOKEN) { + return jsonResponse(res, 401, mcpJsonRpcError(null, -32001, 'MCP 内部接口未授权。')); + } + + let payload; + try { + payload = await readJsonBody(req); + } catch (err) { + return jsonResponse(res, 400, mcpJsonRpcError(null, -32700, err?.message || '请求体无效。')); + } + + const context = { + sourceSessionId: sanitizeId(url.searchParams.get('sourceSessionId') || ''), + sourceHopCount: Number.parseInt(String(url.searchParams.get('sourceHopCount') || 0), 10) || 0, + }; + + const messages = Array.isArray(payload) ? payload : [payload]; + const responses = messages + .map((message) => handleMcpJsonRpcMessage(message, context)) + .filter(Boolean); + + if (responses.length === 0) { + res.writeHead(202, { 'Cache-Control': 'no-cache' }); + res.end(); + return; + } + + return jsonResponse(res, 200, Array.isArray(payload) ? responses : responses[0]); +} + async function handleInternalMcpApi(req, res) { const token = getInternalMcpRequestToken(req); if (!token || token !== INTERNAL_MCP_TOKEN) { @@ -5845,6 +5985,10 @@ function recoverProcesses() { const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); + if (url.pathname === '/api/internal/mcp/stream') { + return handleSharedMcpHttpApi(req, res, url); + } + if (req.method === 'POST' && url.pathname === '/api/internal/mcp') { return handleInternalMcpApi(req, res); } @@ -8930,6 +9074,9 @@ function buildCodexAppClientSpec() { delete env.CC_WEB_PASSWORD; delete env.CLAUDECODE; delete env.CLAUDE_CODE; + if (CODEX_APP_CCWEB_MCP_TRANSPORT !== 'stdio') { + env[CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV] = INTERNAL_MCP_TOKEN; + } if (runtimeConfig?.mode === 'custom') { env.CODEX_HOME = runtimeConfig.homeDir; env.OPENAI_API_KEY = runtimeConfig.apiKey; @@ -8945,6 +9092,8 @@ function buildCodexAppClientSpec() { codeHome: env.CODEX_HOME || '', apiKeyHash: runtimeConfig?.apiKey ? crypto.createHash('sha256').update(runtimeConfig.apiKey).digest('hex') : '', worker: CODEX_APP_WORKER_ENABLED, + ccwebMcpTransport: CODEX_APP_CCWEB_MCP_TRANSPORT, + internalMcpTokenHash: crypto.createHash('sha256').update(INTERNAL_MCP_TOKEN).digest('hex'), }); return {