From ebff7f57b78846d4fe09fe1250c0800ab2a03ca8 Mon Sep 17 00:00:00 2001 From: GA-20 Date: Mon, 20 Jul 2026 18:08:20 +0800 Subject: [PATCH 1/3] feat: support configurable model display names --- desktop/src/index.html | 24 +++++++++++- desktop/src/main.js | 38 +++++++++++++++---- desktop/src/model-catalog-state.js | 53 +++++++++++++++++++------- desktop/src/styles.css | 3 ++ test/model_catalog_state.test.mjs | 60 +++++++++++++++++++++++++++++- 5 files changed, 154 insertions(+), 24 deletions(-) diff --git a/desktop/src/index.html b/desktop/src/index.html index 34ed503..8e2379e 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -131,7 +131,17 @@

配置方案

-
尚未探测模型。
可以选择推荐模型,也可以直接填写供应商或中转站提供的精确模型 ID。
+
+ +
尚未探测模型。
+
+
默认模型(均衡)
+
高质量模型(可选)
+
快速模型(可选)
+
Fable 模型(可选)
+
模型 ID 用于上游请求;显示名称用于 Science 模型菜单(对应 display_name),留空则使用模型 ID。
+
+
只存本地;面板仅回显末 4 位。
@@ -141,7 +151,17 @@

配置方案

编辑连接

-
尚未探测模型。
可以选择推荐模型,也可以直接填写供应商或中转站提供的精确模型 ID。
+
+ +
尚未探测模型。
+
+
默认模型(均衡)
+
高质量模型(可选)
+
快速模型(可选)
+
Fable 模型(可选)
+
模型 ID 用于上游请求;显示名称用于 Science 模型菜单(对应 display_name),留空则使用模型 ID。
+
+
留空=不改;要移除请使用“清除 key”。
diff --git a/desktop/src/main.js b/desktop/src/main.js index 9984383..5418787 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -842,8 +842,14 @@ function setBusy(on, op) { els.runtimeUseCacheBtn, els.runtimeDownloadBtn, els.runtimeChoiceCancelBtn, els.wizSaveBtn, els.wizFetchBtn, els.wizCancelBtn, els.connSaveBtn, els.connFetchBtn, els.connClearBtn, els.connCancelBtn, - els.wizModel, els.wizRoleQuality, els.wizRoleFast, els.wizRoleFable, - els.connModel, els.connRoleQuality, els.connRoleFast, els.connRoleFable, + els.wizModel, els.wizModelDisplayName, + els.wizRoleQuality, els.wizRoleQualityDisplayName, + els.wizRoleFast, els.wizRoleFastDisplayName, + els.wizRoleFable, els.wizRoleFableDisplayName, + els.connModel, els.connModelDisplayName, + els.connRoleQuality, els.connRoleQualityDisplayName, + els.connRoleFast, els.connRoleFastDisplayName, + els.connRoleFable, els.connRoleFableDisplayName, els.metaSaveBtn, els.metaCancelBtn, els.skipActivateBtn, els.codexEnabled, els.codexStatusBtn, els.codexLoginBtn, els.codexRepairProfileBtn, els.codexCancelBtn, els.codexLogoutBtn, els.codexNetworkMode, els.codexProxyUrl, @@ -1752,11 +1758,15 @@ function editorState(kind) { const wizard = kind === "wizard"; return { model: wizard ? els.wizModel : els.connModel, + modelDisplay: wizard ? els.wizModelDisplayName : els.connModelDisplayName, staticRoot: wizard ? els.wizStaticCatalog : els.connStaticCatalog, warning: wizard ? els.wizCatalogWarning : els.connCatalogWarning, quality: wizard ? els.wizRoleQuality : els.connRoleQuality, + qualityDisplay: wizard ? els.wizRoleQualityDisplayName : els.connRoleQualityDisplayName, fast: wizard ? els.wizRoleFast : els.connRoleFast, + fastDisplay: wizard ? els.wizRoleFastDisplayName : els.connRoleFastDisplayName, fable: wizard ? els.wizRoleFable : els.connRoleFable, + fableDisplay: wizard ? els.wizRoleFableDisplayName : els.connRoleFableDisplayName, get catalog() { return wizard ? wizardCatalog : connectionCatalog; }, set catalog(value) { if (wizard) wizardCatalog = value; else connectionCatalog = value; }, get discovered() { return wizard ? wizardDiscoveredCatalog : connectionDiscoveredCatalog; }, @@ -1788,9 +1798,13 @@ function initializeCatalogEditor(kind, routes, defaultRef, bindings, { dynamic = editor.catalog = []; editor.discovered = []; editor.model.value = ""; + editor.modelDisplay.value = ""; editor.quality.value = ""; + editor.qualityDisplay.value = ""; editor.fast.value = ""; + editor.fastDisplay.value = ""; editor.fable.value = ""; + editor.fableDisplay.value = ""; return; } editor.discovered = []; @@ -1799,9 +1813,13 @@ function initializeCatalogEditor(kind, routes, defaultRef, bindings, { dynamic = editor.refs = roleReferences(bindings, fallback, defaultRef || fallback); const fields = projectSimpleModelFields(editor.catalog, editor.refs.default, bindings); editor.model.value = fields.default_model; + editor.modelDisplay.value = fields.default_display_name; editor.quality.value = fields.quality_model; + editor.qualityDisplay.value = fields.quality_display_name; editor.fast.value = fields.fast_model; + editor.fastDisplay.value = fields.fast_display_name; editor.fable.value = fields.fable_model; + editor.fableDisplay.value = fields.fable_display_name; renderModelOptions(editor.model, editor.catalog.map((item) => ({ id: item.upstream_model, supports_tools: item.supports_tools, @@ -1809,16 +1827,20 @@ function initializeCatalogEditor(kind, routes, defaultRef, bindings, { dynamic = const preservesLegacyBalanced = kind === "connection" && editor.refs.balanced !== editor.refs.default; editor.warning.textContent = preservesLegacyBalanced ? "这个旧配置曾单独保存均衡映射:不修改默认模型时会原样保留;修改默认模型后,均衡会随默认更新。" - : "可以选择推荐模型,也可以直接填写供应商或中转站提供的精确模型 ID。"; + : "模型 ID 用于上游请求;显示名称用于 Science 模型菜单(对应 display_name),留空则使用模型 ID。"; } function catalogSubmission(kind) { const editor = editorState(kind); return buildSimpleModelSubmission({ default_model: editor.model.value, + default_display_name: editor.modelDisplay.value, quality_model: editor.quality.value, + quality_display_name: editor.qualityDisplay.value, fast_model: editor.fast.value, + fast_display_name: editor.fastDisplay.value, fable_model: editor.fable.value, + fable_display_name: editor.fableDisplay.value, }, { existing_routes: editor.catalog, candidate_routes: editor.discovered, @@ -2663,9 +2685,9 @@ function wire() { "codexNetworkMode", "codexProxyUrl", "codexNetworkResolved", "codexNetworkSaveBtn", "codexDowngradeBox", "codexDowngradeBtn", "connectionOverview", "listSec", "profileList", "newBtn", "skipActivateBtn", "wizSec", "wizTemplate", "wizTemplateChips", "wizTplLabel", "wizTplHint", "wizName", "wizBaseGroup", "wizBase", "wizBaseHint", - "wizModelGroup", "wizModelLabel", "wizFetchBtn", "wizModelInfo", "wizModel", "wizModelHint", "wizCodexCatalog", "wizCodexCatalogMeta", "wizCodexCatalogList", "wizStaticCatalog", "wizRoleQuality", "wizRoleFast", "wizRoleFable", "wizCatalogWarning", "wizKeyGroup", "wizKey", "wizSaveBtn", "wizCancelBtn", + "wizModelGroup", "wizModelLabel", "wizFetchBtn", "wizModelInfo", "wizModel", "wizModelDisplayName", "wizModelHint", "wizCodexCatalog", "wizCodexCatalogMeta", "wizCodexCatalogList", "wizStaticCatalog", "wizRoleQuality", "wizRoleQualityDisplayName", "wizRoleFast", "wizRoleFastDisplayName", "wizRoleFable", "wizRoleFableDisplayName", "wizCatalogWarning", "wizKeyGroup", "wizKey", "wizSaveBtn", "wizCancelBtn", "connSec", "connTitle", "connBaseGroup", "connBase", "connBaseHint", "connFetchBtn", - "connModelGroup", "connModelLabel", "connModelInfo", "connModel", "connModelHint", "connCodexCatalog", "connCodexCatalogMeta", "connCodexCatalogList", "connStaticCatalog", "connRoleQuality", "connRoleFast", "connRoleFable", "connCatalogWarning", "connKeyGroup", "connKey", "connSaveBtn", "connClearBtn", "connCancelBtn", + "connModelGroup", "connModelLabel", "connModelInfo", "connModel", "connModelDisplayName", "connModelHint", "connCodexCatalog", "connCodexCatalogMeta", "connCodexCatalogList", "connStaticCatalog", "connRoleQuality", "connRoleQualityDisplayName", "connRoleFast", "connRoleFastDisplayName", "connRoleFable", "connRoleFableDisplayName", "connCatalogWarning", "connKeyGroup", "connKey", "connSaveBtn", "connClearBtn", "connCancelBtn", "metaSec", "metaName", "metaNotes", "metaSaveBtn", "metaCancelBtn", "themeBtn", "pageEyebrow", "pageTitle", "pageSubtitle", "currentProfileIcon", "currentProfileName", "currentProfileState", "currentRouteMode", "currentProfileModel", "currentProfileMeta", @@ -2740,13 +2762,15 @@ function wire() { const chip = e.target.closest(".chip"); if (chip) selectWizTemplate(chip.getAttribute("data-tid")); }); - [els.wizModel, els.wizRoleQuality, els.wizRoleFast, els.wizRoleFable] + [els.wizModel, els.wizModelDisplayName, els.wizRoleQuality, els.wizRoleQualityDisplayName, + els.wizRoleFast, els.wizRoleFastDisplayName, els.wizRoleFable, els.wizRoleFableDisplayName] .forEach((input) => input.addEventListener("input", () => catalogRolesChanged("wizard"))); els.wizFetchBtn.addEventListener("click", wizFetch); els.wizSaveBtn.addEventListener("click", wizSave); els.wizCancelBtn.addEventListener("click", cancelForm); - [els.connModel, els.connRoleQuality, els.connRoleFast, els.connRoleFable] + [els.connModel, els.connModelDisplayName, els.connRoleQuality, els.connRoleQualityDisplayName, + els.connRoleFast, els.connRoleFastDisplayName, els.connRoleFable, els.connRoleFableDisplayName] .forEach((input) => input.addEventListener("input", () => catalogRolesChanged("connection"))); els.connFetchBtn.addEventListener("click", connFetch); els.connSaveBtn.addEventListener("click", connSave); diff --git a/desktop/src/model-catalog-state.js b/desktop/src/model-catalog-state.js index 7bd9a5a..a48eb39 100644 --- a/desktop/src/model-catalog-state.js +++ b/desktop/src/model-catalog-state.js @@ -239,8 +239,18 @@ export function summarizeProfileRoleModels(profile) { }; } -function simplifiedRoute(route, upstreamModel) { - const display = boundedText(route?.display_name); +function optionalDisplayName(value, label) { + const raw = String(value ?? ""); + if (!raw.trim()) return ""; + const display = boundedText(raw, 256); + if (!display) throw new Error(`${label}不能含控制字符,且最长 256 字节。`); + return display; +} + +function simplifiedRoute(route, upstreamModel, submittedDisplayName) { + const submitted = optionalDisplayName(submittedDisplayName, "显示名称"); + const saved = boundedText(route?.display_name, 256); + const display = submitted || (submittedDisplayName === undefined ? saved : ""); return { selector_id: boundedText(route?.selector_id, 160), display_name: !display || display.toLowerCase() === "default" ? upstreamModel : display, @@ -254,14 +264,21 @@ export function projectSimpleModelFields(routes, defaultReference, bindings = {} const catalog = (routes || []).map((route) => normalizeCatalogCandidate(route, true)).filter(Boolean); const fallback = routeByReference(catalog, defaultReference) || catalog[0] || null; const defaultModel = fallback?.upstream_model || ""; - const qualityModel = (routeByReference(catalog, bindings.opus) || fallback)?.upstream_model || defaultModel; - const fastModel = (routeByReference(catalog, bindings.haiku) || fallback)?.upstream_model || defaultModel; - const fableModel = (routeByReference(catalog, bindings.fable) || routeByReference(catalog, bindings.opus) || fallback)?.upstream_model || qualityModel || defaultModel; + const qualityRoute = routeByReference(catalog, bindings.opus) || fallback; + const fastRoute = routeByReference(catalog, bindings.haiku) || fallback; + const fableRoute = routeByReference(catalog, bindings.fable) || qualityRoute || fallback; + const qualityModel = qualityRoute?.upstream_model || defaultModel; + const fastModel = fastRoute?.upstream_model || defaultModel; + const fableModel = fableRoute?.upstream_model || qualityModel || defaultModel; return { default_model: defaultModel, + default_display_name: fallback?.display_name || defaultModel, quality_model: qualityModel && qualityModel !== defaultModel ? qualityModel : "", + quality_display_name: qualityModel && qualityModel !== defaultModel ? (qualityRoute?.display_name || qualityModel) : "", fast_model: fastModel && fastModel !== defaultModel ? fastModel : "", + fast_display_name: fastModel && fastModel !== defaultModel ? (fastRoute?.display_name || fastModel) : "", fable_model: fableModel && fableModel !== qualityModel ? fableModel : "", + fable_display_name: fableModel && fableModel !== qualityModel ? (fableRoute?.display_name || fableModel) : "", }; } @@ -270,6 +287,10 @@ export function buildSimpleModelSubmission(fields, options = {}) { const qualityInput = exactModelId(fields?.quality_model, "高质量模型"); const fastInput = exactModelId(fields?.fast_model, "快速模型"); const fableInput = exactModelId(fields?.fable_model, "Fable 模型"); + const defaultDisplayName = optionalDisplayName(fields?.default_display_name, "默认模型显示名称"); + const qualityDisplayName = optionalDisplayName(fields?.quality_display_name, "高质量模型显示名称"); + const fastDisplayName = optionalDisplayName(fields?.fast_display_name, "快速模型显示名称"); + const fableDisplayName = optionalDisplayName(fields?.fable_display_name, "Fable 模型显示名称"); const qualityModel = qualityInput || defaultModel; const fastModel = fastInput || defaultModel; const fableModel = fableInput || qualityModel; @@ -286,16 +307,20 @@ export function buildSimpleModelSubmission(fields, options = {}) { const selectedRoutes = []; const selectedKeys = new Set(); - const choose = (model, preferredReference) => { + const choose = (model, displayName, preferredReference) => { const preferredRoute = routeByReference(existingRoutes, preferredReference); - const existing = preferredRoute?.upstream_model === model + const savedRoute = preferredRoute?.upstream_model === model ? preferredRoute - : existingRoutes.find((route) => route.upstream_model === model) - || candidateRoutes.find((route) => route.upstream_model === model); + : existingRoutes.find((route) => route.upstream_model === model); + const candidateRoute = savedRoute + ? null + : candidateRoutes.find((route) => route.upstream_model === model); + const existing = savedRoute || candidateRoute; if (existing?.supports_tools === false) { throw new Error(`模型 ${model} 已明确不支持 tools,不能用于 Science。`); } - const route = simplifiedRoute(existing, model); + const resolvedDisplayName = displayName || candidateRoute?.display_name || ""; + const route = simplifiedRoute(existing, model, resolvedDisplayName); const key = route.selector_id ? `selector:${route.selector_id}` : `upstream:${route.upstream_model}`; if (!selectedKeys.has(key)) { selectedKeys.add(key); @@ -304,11 +329,11 @@ export function buildSimpleModelSubmission(fields, options = {}) { return routeReference(route); }; - const defaultRef = choose(defaultModel, preferred.default); + const defaultRef = choose(defaultModel, defaultDisplayName, preferred.default); // 留空的含义是继承同一条 selector,而不只是碰巧指向同名 upstream。 - const qualityRef = qualityInput ? choose(qualityModel, preferred.quality) : defaultRef; - const fastRef = fastInput ? choose(fastModel, preferred.fast) : defaultRef; - const fableRef = fableInput ? choose(fableModel, preferred.fable) : qualityRef; + const qualityRef = qualityInput ? choose(qualityModel, qualityDisplayName, preferred.quality) : defaultRef; + const fastRef = fastInput ? choose(fastModel, fastDisplayName, preferred.fast) : defaultRef; + const fableRef = fableInput ? choose(fableModel, fableDisplayName, preferred.fable) : qualityRef; const previousDefault = routeByReference(existingRoutes, preferred.default); const previousSonnet = routeByReference(existingRoutes, preferred.balanced); const preserveSonnet = options.preserve_existing_sonnet === true diff --git a/desktop/src/styles.css b/desktop/src/styles.css index b03aac0..1c88f13 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -412,7 +412,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); backgro .catalog-field{grid-column:1 / -1} .simple-model-editor{margin-top:8px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--field)} .simple-model-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} +.simple-model-route{min-width:0;margin:0;padding:9px;border:1px solid var(--line);border-radius:8px} +.simple-model-route legend{padding:0 4px;color:var(--ink);font-size:10px;font-weight:700} .simple-model-grid label{margin:0;color:var(--sub);font-size:10px} +.simple-model-route label+label{display:block;margin-top:7px} .simple-model-grid input{margin-top:4px;font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace} .simple-model-editor>.hint{margin-top:8px} .static-catalog{margin-top:9px;padding:9px;border:1px solid var(--line);border-radius:10px;background:var(--field)} diff --git a/test/model_catalog_state.test.mjs b/test/model_catalog_state.test.mjs index 5b10920..6c0ce82 100644 --- a/test/model_catalog_state.test.mjs +++ b/test/model_catalog_state.test.mjs @@ -174,7 +174,10 @@ test("simple model projection preserves selectors and unbound legacy routes", () sonnet: "sel-default", opus: "sel-quality", haiku: "sel-default", fable: "sel-quality", }); assert.deepEqual(fields, { - default_model: "balanced", quality_model: "quality", fast_model: "", fable_model: "", + default_model: "balanced", default_display_name: "default", + quality_model: "quality", quality_display_name: "Quality", + fast_model: "", fast_display_name: "", + fable_model: "", fable_display_name: "", }); const payload = buildSimpleModelSubmission(fields, { existing_routes: existing, @@ -185,6 +188,54 @@ test("simple model projection preserves selectors and unbound legacy routes", () assert.ok(payload.model_catalog.some((route) => route.selector_id === "sel-extra")); }); +test("editable display names stay separate from exact upstream model IDs", () => { + const existing = [ + { + selector_id: "claude-csswitch-custom-openai-responses-deepseek-v4-pro-111111111111", + upstream_model: "deepseek-v4-pro", + display_name: "deepseek-v4-pro", + supports_tools: null, + }, + { + selector_id: "claude-csswitch-custom-openai-responses-deepseek-v4-flash-222222222222", + upstream_model: "deepseek-v4-flash", + display_name: "deepseek-v4-flash", + supports_tools: null, + }, + ]; + const refs = { + default: existing[0].selector_id, + balanced: existing[0].selector_id, + quality: existing[0].selector_id, + fast: existing[1].selector_id, + fable: existing[0].selector_id, + }; + const fields = projectSimpleModelFields(existing, refs.default, { + sonnet: refs.balanced, opus: refs.quality, haiku: refs.fast, fable: refs.fable, + }); + assert.equal(fields.default_display_name, "deepseek-v4-pro"); + assert.equal(fields.fast_display_name, "deepseek-v4-flash"); + + const payload = buildSimpleModelSubmission({ + ...fields, + default_display_name: "DeepSeek V4 Pro", + fast_display_name: "DeepSeek V4 Flash", + }, { + existing_routes: existing, + existing_references: refs, + preserve_existing_sonnet: true, + }); + assert.deepEqual(payload.model_catalog.map((route) => ({ + display_name: route.display_name, + upstream_model: route.upstream_model, + })), [ + { display_name: "DeepSeek V4 Pro", upstream_model: "deepseek-v4-pro" }, + { display_name: "DeepSeek V4 Flash", upstream_model: "deepseek-v4-flash" }, + ]); + assert.equal(payload.default_model_route_id, existing[0].selector_id); + assert.equal(payload.role_bindings.haiku, existing[1].selector_id); +}); + test("blank optional fields inherit the exact selector selected by their parent role", () => { const existing = [ { selector_id: "sel-default", upstream_model: "same", display_name: "Same", supports_tools: true }, @@ -284,11 +335,18 @@ test("role summary groups SiliconFlow roles and stays quiet for one model or dyn test("provider forms expose four free model inputs plus read-only scratch discovery", () => { const html = readFileSync(new URL("../desktop/src/index.html", import.meta.url), "utf8"); + const js = readFileSync(new URL("../desktop/src/main.js", import.meta.url), "utf8"); + const busyControls = js.slice(js.indexOf("function setBusy("), js.indexOf("function syncOpenBrowserControl(")); for (const prefix of ["wiz", "conn"]) { for (const id of [`${prefix}Model`, `${prefix}RoleQuality`, `${prefix}RoleFast`, `${prefix}RoleFable`]) { assert.match(html, new RegExp(`]*>获取可用模型]*>获取可用模型 Date: Mon, 20 Jul 2026 18:25:46 +0800 Subject: [PATCH 2/3] fix: support current Science nonce authentication --- desktop/skill-package/src/science.rs | 137 +++++++++++++++++++++++---- 1 file changed, 119 insertions(+), 18 deletions(-) diff --git a/desktop/skill-package/src/science.rs b/desktop/skill-package/src/science.rs index b46fa23..b08c2af 100644 --- a/desktop/skill-package/src/science.rs +++ b/desktop/skill-package/src/science.rs @@ -9,7 +9,7 @@ use std::thread; use std::time::{Duration, Instant}; use reqwest::blocking::{Client, Response}; -use reqwest::header::{CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; +use reqwest::header::{ACCEPT, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; use reqwest::redirect::Policy; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -650,18 +650,42 @@ struct ControlSession { } fn authenticate(client: &Client, origin: &str, nonce: &str) -> Result { - let auth = client - .post(format!("{origin}/api/auth/nonce")) + // Current Science consumes the nonce from the browser URL itself. Older + // releases exposed POST /api/auth/nonce instead, so retain that endpoint + // as an explicit compatibility path only when the browser exchange does + // not yield an authenticated session. + let browser_auth = client + .get(format!("{origin}/?nonce={nonce}")) .header(ORIGIN, origin) - .form(&[("nonce", nonce), ("dest", "/")]) - .send() - .map_err(|_| { - AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 认证失败").retryable(true) - })?; - ensure_success(&auth, "nonce 认证")?; - let auth_cookie = response_cookie(&auth, "operon_auth").ok_or_else(|| { - AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 未返回会话 cookie") - })?; + .header(ACCEPT, "text/html") + .send(); + let browser_cookies = browser_auth.as_ref().ok().and_then(|response| { + (response.status().is_success() || response.status().is_redirection()).then(|| { + ( + response_cookie(response, "operon_auth"), + response_cookie(response, "operon_csrf"), + ) + }) + }); + let (auth_cookie, mut csrf_cookie) = match browser_cookies { + Some((Some(auth_cookie), csrf_cookie)) => (auth_cookie, csrf_cookie), + _ => { + let auth = client + .post(format!("{origin}/api/auth/nonce")) + .header(ORIGIN, origin) + .form(&[("nonce", nonce), ("dest", "/")]) + .send() + .map_err(|_| { + AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 认证失败") + .retryable(true) + })?; + ensure_success(&auth, "legacy nonce 认证")?; + let auth_cookie = response_cookie(&auth, "operon_auth").ok_or_else(|| { + AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 未返回会话 cookie") + })?; + (auth_cookie, response_cookie(&auth, "operon_csrf")) + } + }; let csrf = client .get(format!("{origin}/api/csrf")) .header(ORIGIN, origin) @@ -669,7 +693,8 @@ fn authenticate(client: &Client, origin: &str, nonce: &str) -> Result, body: &str) { - let cookie = cookie + let cookies = cookie.into_iter().collect::>(); + reply_with_cookies(stream, &cookies, body); + } + + fn reply_with_cookies(stream: &mut TcpStream, cookies: &[&str], body: &str) { + let cookies = cookies + .iter() .map(|value| format!("Set-Cookie: {value}; Path=/; SameSite=Strict\r\n")) - .unwrap_or_default(); + .collect::(); let response = format!( - "HTTP/1.1 200 OK\r\n{cookie}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 200 OK\r\n{cookies}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); stream.write_all(response.as_bytes()).unwrap(); @@ -1061,8 +1092,7 @@ printf '%s\n' 'http://127.0.0.1:18990/?nonce=fresh-one'"#, ); worker.join().unwrap(); let requests = requests.lock().unwrap(); - assert!(requests[0].starts_with("POST /api/auth/nonce ")); - assert!(requests[0].contains("nonce=fresh-nonce")); + assert!(requests[0].starts_with("GET /?nonce=fresh-nonce ")); assert!(requests[1].starts_with("GET /api/csrf ")); assert!(requests[2].starts_with("GET /api/agents?names=OPERON&include_metadata=true ")); assert!(requests[3].starts_with("POST /api/agents/OPERON/skills ")); @@ -1074,6 +1104,77 @@ printf '%s\n' 'http://127.0.0.1:18990/?nonce=fresh-one'"#, fs::remove_dir_all(context.home.parent().unwrap()).unwrap(); } + #[test] + fn browser_nonce_may_supply_auth_and_csrf_cookies_together() { + let Some(listener) = bind_loopback() else { + return; + }; + let port = listener.local_addr().unwrap().port(); + let worker = thread::spawn(move || { + let (mut auth, _) = listener.accept().unwrap(); + assert!(read_request(&mut auth).starts_with("GET /?nonce=fresh-nonce ")); + reply_with_cookies( + &mut auth, + &["operon_auth=auth-token", "operon_csrf=csrf-token"], + "{}", + ); + let (mut csrf, _) = listener.accept().unwrap(); + assert!(read_request(&mut csrf).starts_with("GET /api/csrf ")); + reply(&mut csrf, None, "{}"); + }); + let client = Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .redirect(Policy::none()) + .no_proxy() + .build() + .unwrap(); + let session = + authenticate(&client, &format!("http://127.0.0.1:{port}"), "fresh-nonce").unwrap(); + assert_eq!(session.auth_cookie, "auth-token"); + assert_eq!(session.csrf_cookie, "csrf-token"); + worker.join().unwrap(); + } + + #[test] + fn legacy_nonce_endpoint_remains_an_explicit_compatibility_path() { + let Some(listener) = bind_loopback() else { + return; + }; + let port = listener.local_addr().unwrap().port(); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = requests.clone(); + let worker = thread::spawn(move || { + let replies = [ + (None, "{}"), + (Some("operon_auth=auth-token"), "{}"), + (Some("operon_csrf=csrf-token"), "{}"), + ]; + for (cookie, body) in replies { + let (mut stream, _) = listener.accept().unwrap(); + captured.lock().unwrap().push(read_request(&mut stream)); + reply(&mut stream, cookie, body); + } + }); + let client = Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .redirect(Policy::none()) + .no_proxy() + .build() + .unwrap(); + let session = + authenticate(&client, &format!("http://127.0.0.1:{port}"), "fresh-nonce").unwrap(); + assert_eq!(session.auth_cookie, "auth-token"); + assert_eq!(session.csrf_cookie, "csrf-token"); + worker.join().unwrap(); + let requests = requests.lock().unwrap(); + assert!(requests[0].starts_with("GET /?nonce=fresh-nonce ")); + assert!(requests[1].starts_with("POST /api/auth/nonce ")); + assert!(requests[1].contains("nonce=fresh-nonce")); + assert!(requests[2].starts_with("GET /api/csrf ")); + } + #[test] fn strict_list_readback_rejects_missing_operon_and_malformed_skills() { for body in [ From 8f3d1d36b6bfc2e92256a926d2f3fdca96b96ea9 Mon Sep 17 00:00:00 2001 From: GA-20 Date: Tue, 21 Jul 2026 01:30:50 +0800 Subject: [PATCH 3/3] feat: allow concurrent official and isolated Science --- desktop/src-tauri/src/commands/runtime.rs | 60 ++++++++++++----- desktop/src-tauri/src/runtime/science.rs | 79 +++++++++++++++++++++-- desktop/src/index.html | 14 ++-- desktop/src/main.js | 36 +++++------ desktop/src/runtime-status-state.js | 3 +- test/__init__.py | 5 ++ test/runtime_status_state.test.mjs | 35 +++++++++- test/test_skill_runtime_boundary.py | 3 +- 8 files changed, 183 insertions(+), 52 deletions(-) create mode 100644 test/__init__.py diff --git a/desktop/src-tauri/src/commands/runtime.rs b/desktop/src-tauri/src/commands/runtime.rs index d591dab..52626f3 100644 --- a/desktop/src-tauri/src/commands/runtime.rs +++ b/desktop/src-tauri/src/commands/runtime.rs @@ -97,21 +97,23 @@ pub(crate) fn stop_sandbox_state( result } -/// 切换运行模式("proxy" 第三方 / "official" 官方)。切官方要先拆第三方链路成功再落盘。 +/// 切换当前操作视图("proxy" 第三方 / "official" 官方)。 +/// +/// 官方 Science 是不受 CSSwitch 管理的独立实例;切换视图不得拆掉已经运行的 +/// 第三方 Gateway/隔离 Science。串行锁只用于等待在途 start/stop 完成后再保存视图, +/// 防止配置更新与运行事务交错。 #[tauri::command] pub(crate) async fn set_mode( - app: tauri::AppHandle, state: State<'_, SharedAppState>, lifecycle: State<'_, SharedLifecycle>, mode: String, ) -> Result<(), String> { let state = state.inner().clone(); let lifecycle = lifecycle.inner().clone(); - run_blocking(move || set_mode_inner(app, state, lifecycle, mode)).await + run_blocking(move || set_mode_inner(state, lifecycle, mode)).await } fn set_mode_inner( - app: tauri::AppHandle, state: SharedAppState, lifecycle: SharedLifecycle, mode: String, @@ -119,19 +121,10 @@ fn set_mode_inner( if mode != "proxy" && mode != "official" { return Err(format!("未知模式:{mode}(只支持 proxy / official)。")); } - // 经串行器(修 P1-b):切官方的「拆链路 + 落盘」必须与「一键开始」等互斥,否则一键起到一半时 - // 切官方会先停链路、一键随后又把沙箱/OAuth 起起来 → 显示官方却有第三方沙箱在跑。bump_generation - // 作废任何在途启动,防被停后又拿旧配置写回运行态。 + // 模式现在只表示 UI 操作视图。仍经串行器与「一键开始」等互斥:在途启动先完整 + // 成功或失败,随后才保存新视图;不停止、不重启,也不作废已完成的第三方链路。 lifecycle.with_serialized(|| { let dir = config::default_dir(); - if mode == "official" { - lifecycle.bump_generation(); - let mut st = lock(&state); - stop_sandbox_state(&app, &mut st).map_err(|e| { - format!("停止沙箱失败,未切换到官方模式:{e}(真实实例 8765 未受影响)") - })?; - st.stop_proxy(); - } config::update(&dir, { let mode = mode.clone(); move |c| c.mode = mode @@ -1434,6 +1427,43 @@ esac "7" ); + let running_pid = fs::read_to_string(fake_state_dir.join("pid")).unwrap(); + super::set_mode_inner(state.clone(), lifecycle.clone(), "official".into()) + .expect("switching to the official view must preserve the managed runtime"); + assert_eq!(config::load_from(&config_dir).unwrap().mode, "official"); + wait_http_health(proxy_port); + wait_http_health(sandbox_port); + { + let st = lock(&state); + assert!(st.proxy.is_some(), "Gateway child must remain tracked"); + assert!( + st.science_runtime.is_some(), + "isolated Science identity must remain tracked" + ); + } + assert_eq!( + fs::read_to_string(fake_state_dir.join("pid")).unwrap(), + running_pid, + "switching views must not restart isolated Science" + ); + + super::set_mode_inner(state.clone(), lifecycle.clone(), "proxy".into()) + .expect("switching back to the third-party view should only persist the view"); + let reopened = sandbox_session::one_click_login( + handle.clone(), + state.clone(), + lifecycle.as_ref(), + None, + None, + ) + .expect("one-click after a view round-trip should reuse isolated Science"); + assert_eq!(reopened["action"], "reopened"); + assert_eq!( + fs::read_to_string(fake_state_dir.join("pid")).unwrap(), + running_pid, + "view round-trip must preserve the third-party session" + ); + let status = super::status(app.state::()); assert_eq!(status["proxy"], "green"); assert_eq!(status["sandbox"], "green"); diff --git a/desktop/src-tauri/src/runtime/science.rs b/desktop/src-tauri/src/runtime/science.rs index c058746..b2377fb 100644 --- a/desktop/src-tauri/src/runtime/science.rs +++ b/desktop/src-tauri/src/runtime/science.rs @@ -635,7 +635,25 @@ fn test_listener_marker_matches(pid: &str, runtime: &ScienceRuntimeIdentity) -> .is_some_and(|recorded| recorded.trim() == pid) } -/// Return the sandbox UI URL, falling back to the plain localhost port. +/// Put the CSSwitch-managed browser session on a distinct cookie origin from +/// the official Science instance. Browser cookies are scoped by host, not by +/// port, so opening both instances as `localhost:` lets either login +/// replace the other's session cookie even though their daemons and data dirs +/// are isolated. +fn isolate_sandbox_browser_origin(raw: &str, port: u16) -> Option { + let localhost = format!("http://localhost:{port}"); + let loopback = format!("http://127.0.0.1:{port}"); + let suffix = raw + .strip_prefix(&localhost) + .or_else(|| raw.strip_prefix(&loopback))?; + if !suffix.is_empty() && !suffix.starts_with('/') && !suffix.starts_with('?') { + return None; + } + Some(format!("{loopback}{suffix}")) +} + +/// Return the sandbox UI URL on the dedicated `127.0.0.1` browser origin, +/// falling back to the plain loopback port when the CLI emits no usable URL. pub(crate) fn sandbox_url(port: u16, runtime: &ScienceRuntimeIdentity) -> String { let home = sandbox_home(); let data_dir = sandbox_data_dir(); @@ -648,7 +666,9 @@ pub(crate) fn sandbox_url(port: u16, runtime: &ScienceRuntimeIdentity) -> String { let s = String::from_utf8_lossy(&out.stdout); if let Some(url) = first_http_url(&s) { - return url; + if let Some(isolated) = isolate_sandbox_browser_origin(&url, port) { + return isolated; + } } } format!("http://127.0.0.1:{port}") @@ -834,8 +854,9 @@ mod tests { use std::process::{ExitStatus, Output}; use super::{ - classify_known_runtime_state, classify_sandbox_state, first_http_url, runtime_status_value, - sandbox_home, sandbox_running_ours, sandbox_url, science_runtime_preflight_for_paths, + classify_known_runtime_state, classify_sandbox_state, first_http_url, + isolate_sandbox_browser_origin, runtime_status_value, sandbox_home, sandbox_running_ours, + sandbox_url, science_runtime_preflight_for_paths, science_runtime_preflight_for_paths_cached, science_status_running, select_science_runtime_for_paths, select_science_runtime_for_paths_cached, settings_change_needs_teardown, stop_runtime_from_probe, trusted_science_status, @@ -889,6 +910,32 @@ mod tests { ); } + #[test] + fn sandbox_browser_origin_is_distinct_from_official_localhost_cookie_scope() { + assert_eq!( + isolate_sandbox_browser_origin("http://localhost:8990/?nonce=single-use-token", 8990) + .as_deref(), + Some("http://127.0.0.1:8990/?nonce=single-use-token") + ); + assert_eq!( + isolate_sandbox_browser_origin("http://127.0.0.1:8990/project/one?nonce=token", 8990) + .as_deref(), + Some("http://127.0.0.1:8990/project/one?nonce=token") + ); + assert!( + isolate_sandbox_browser_origin("http://localhost:8765/?nonce=official", 8990).is_none() + ); + assert!(isolate_sandbox_browser_origin( + "http://localhost:8990.evil.invalid/?nonce=bad", + 8990 + ) + .is_none()); + assert!( + isolate_sandbox_browser_origin("https://localhost:8990/?nonce=wrong-scheme", 8990) + .is_none() + ); + } + #[test] fn version_cache_is_shared_and_invalidates_when_binary_changes( ) -> Result<(), Box> { @@ -1207,7 +1254,7 @@ mod tests { } #[test] - fn sandbox_url_falls_back_to_localhost_when_cli_absent() { + fn sandbox_url_falls_back_to_loopback_ip_when_cli_absent() { let root = unique_temp_dir("science-url-fallback").unwrap(); let bin = root.join("claude-science"); write_fake_bin(&bin, 0o755).unwrap(); @@ -1220,6 +1267,28 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn sandbox_url_normalizes_cli_localhost_nonce_to_loopback_ip() { + let root = unique_temp_dir("science-url-cookie-origin").unwrap(); + let bin = root.join("claude-science"); + fs::write( + &bin, + "#!/bin/sh\nprintf '%s\\n' 'http://localhost:8990/?nonce=one-time'\n", + ) + .unwrap(); + fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap(); + let runtime = ScienceRuntimeIdentity { + path: bin, + source: ScienceRuntimeSource::InstalledApp, + version: None, + }; + assert_eq!( + sandbox_url(8990, &runtime), + "http://127.0.0.1:8990/?nonce=one-time" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn sandbox_identity_does_not_trust_health_when_cli_absent() { let root = unique_temp_dir("science-identity-fallback").unwrap(); diff --git a/desktop/src/index.html b/desktop/src/index.html index 8e2379e..69a1695 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -69,13 +69,13 @@

尚未选择配置

- +