fix: MCP bridge fallback, loopback-exempt API key, terminal reconnect, and probe status

- stdio bridge now falls back to in-process handling unless the forward returns a
  real JSON-RPC response. A 401 from an API-key / data-dir mismatch used to be
  printed verbatim as the tool result, so clients never completed the handshake
- MCP API key is enforced only when bound to a non-loopback address; on 127.0.0.1
  it is exempt (local-first, like 1Shell). The remote toggle still governs HOST=0.0.0.0
- vps_terminal "key" action now passes through the command denylist; its fallback
  branch could previously carry arbitrary text and bypass the guard
- vps_exec interactive-prompt early-exit now matches only explicit keywords, so
  ordinary commands whose last line ends in ':' / '?' are no longer aborted mid-run
- terminal: add a "重新连接" button that recreates a pane in place after the session ends
- file browser: downloads go through the resumable + SHA-256 transfer engine
  (default local path = Downloads, runs in background, non-blocking)
- standalone terminal window: hide the dead 文件管理 / 详情图表 buttons
- probe status: an SSH connection means online (probe failure no longer flips to
  red); a connect timeout maps to unknown instead of offline; latency now measures
  the connect only, not the probe
- transfer: a resumed download fails instead of silently accepting when the remote
  hash is unavailable; secret.key is no longer overwritten when corrupt; a per-host
  lock serializes pane creation
This commit is contained in:
weidu12123 committed 2026-10-15 01:47:00 +08:00
1 parent fbf2ac2231
commit 3176ffdc4c
13 files changed
+328 -94

No files matched your search

+37
View File
@@ -763,6 +763,8 @@ async function checkSingleHost(host: PublicHost) {
}
if (data.host.status === 'online') {
showToast(`${data.host.name} 在线 (${data.host.latencyMs ?? 0} ms)`);
} else if (data.host.status === 'unknown') {
showToast(`${data.host.name} 连接超时,未能判定(可能只是线路慢)`, 'err');
} else {
showToast(`${data.host.name} 离线`, 'err');
}
@@ -1003,6 +1005,39 @@ async function handleClosePane(paneId: string) {
}
}
// 终端会话结束后原地重连:为该主机新建一个 pane 顶替退出那个的位置。
// 先建后删——后端 create_pane 会顺手回收同主机已退出的旧 pane,且该主机没有活终端时
// 会释放旧的终端连接、重新登录(横幅照常出现)。
async function handleReconnectPane(paneId: string) {
const tab = openTabs.value.find((t) => t.panes.some((p) => p.id === paneId));
if (!tab || paneCreationInFlight.has(tab.hostId)) return;
const idx = tab.panes.findIndex((p) => p.id === paneId);
const cwd = tab.panes[idx]?.cwd || undefined;
paneCreationInFlight.add(tab.hostId);
paneCreationCount.value += 1;
try {
const res = await fetch('/api/terminals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostId: tab.hostId, cols: 100, rows: 30, cwd: cwd || undefined }),
});
const data = await res.json();
if (!data.ok || !data.pane) {
showToast(data.error || '重连失败', 'err');
return;
}
void fetch(`/api/terminals/${encodeURIComponent(paneId)}`, { method: 'DELETE' });
const at = tab.panes.findIndex((p) => p.id === paneId);
tab.panes.splice(at >= 0 ? at : idx, 1, data.pane as PaneMeta);
showToast('终端已重新连接');
} catch (e) {
showToast(`重连失败: ${(e as Error).message}`, 'err');
} finally {
paneCreationInFlight.delete(tab.hostId);
paneCreationCount.value = Math.max(0, paneCreationCount.value - 1);
}
}
function handlePaneMetaUpdate(updated: PaneMeta) {
for (const tab of openTabs.value) {
const idx = tab.panes.findIndex((p) => p.id === updated.id);
@@ -1292,6 +1327,7 @@ onBeforeUnmount(() => {
:standalone="true"
@split="handleSplitTerminal"
@close-pane="handleClosePane"
@reconnect-pane="handleReconnectPane"
@toggle-dark="toggleTheme"
@meta-update="handlePaneMetaUpdate"
/>
@@ -2490,6 +2526,7 @@ onBeforeUnmount(() => {
:standalone="false"
@split="handleSplitTerminal"
@close-pane="handleClosePane"
@reconnect-pane="handleReconnectPane"
@toggle-dark="terminalDark = !terminalDark"
@toggle-right-wing="toggleRightWing"
@collapse-stage="switchToHomeView"
+34 -11
View File
@@ -109,9 +109,9 @@ const editorContent = ref('');
const editorLoading = ref(false);
const editorSaving = ref(false);
// 新建/重命名/删除确认条
// 新建/重命名/删除确认条 / 下载落点
const inlinePrompt = ref<{
kind: 'mkdir' | 'touch' | 'rename' | 'delete';
kind: 'mkdir' | 'touch' | 'rename' | 'delete' | 'download';
title: string;
value: string;
target?: FileEntry;
@@ -510,14 +510,8 @@ async function saveFileEditor() {
function triggerDownload(item: FileEntry) {
if (item.isDir) return;
ensureTransferPolling();
const url = `/api/files/download?hostId=${encodeURIComponent(props.host.id)}&path=${encodeURIComponent(item.path)}`;
const a = document.createElement('a');
a.href = url;
a.download = item.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 改走带断点续传的传输引擎(见 openInlineAction('download')),不再用浏览器直下
openInlineAction('download', item);
}
function copyEntryPath(path: string) {
@@ -578,7 +572,7 @@ function onDropFiles(e: DragEvent) {
}
function openInlineAction(
kind: 'mkdir' | 'touch' | 'rename' | 'delete',
kind: 'mkdir' | 'touch' | 'rename' | 'delete' | 'download',
target?: FileEntry
) {
if (kind === 'mkdir') {
@@ -594,6 +588,15 @@ function openInlineAction(
value: target.name,
target,
};
} else if (kind === 'download' && target) {
// 走带断点续传 + SHA-256 校验的传输引擎(不再是浏览器直下、断了只能从头)。
// 留空则落到系统下载目录。
inlinePrompt.value = {
kind,
title: `下载「${target.name}」到本机路径(留空 = 系统下载目录)`,
value: '',
target,
};
}
}
@@ -670,6 +673,26 @@ async function submitInlineAction() {
editorOpen.value = false;
}
emit('toast', `已删除 ${act.target.name}`);
} else if (act.kind === 'download' && act.target) {
const res = await fetch('/api/transfers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
direction: 'download',
localPath: val,
remotePath: act.target.path,
}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || '发起下载失败');
inlinePrompt.value = null;
ensureTransferPolling();
emit(
'toast',
`开始下载到 ${data.localPath || '系统下载目录'}(队列中看进度;中断后重下会自动续传)`
);
return;
}
inlinePrompt.value = null;
invalidateCache(props.host.id, currentPath.value);
+23 -11
View File
@@ -18,6 +18,7 @@ const emit = defineEmits<{
focus: [];
closePane: [paneId: string];
metaUpdate: [meta: PaneMeta];
reconnect: [paneId: string];
}>();
const containerRef = ref<HTMLDivElement | null>(null);
@@ -331,20 +332,31 @@ onBeforeUnmount(() => {
</button>
</div>
<!-- 会话已退出提示条 -->
<!-- 会话已结束提示条:远端断开或进程退出都走这里。SSH 掉线无法在字节层恢复,
只能重建一个该主机的新会话(后端会在没有活终端时释放旧连接、重新登录)。 -->
<div
v-if="exited"
class="flex items-center justify-between border-t border-zinc-200/80 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-500"
class="flex items-center justify-between gap-2 border-t border-zinc-200/80 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-400"
>
<span>终端进程已退出</span>
<button
v-if="showClose"
type="button"
class="rounded-md bg-zinc-900 px-2 py-0.5 text-white"
@click="emit('closePane', paneId)"
>
移除面板
</button>
<span>终端会话已结束(远端断开或进程退出)</span>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="rounded-md bg-zinc-900 px-2 py-0.5 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white"
title="为该主机重新建立一个终端会话"
@click="emit('reconnect', paneId)"
>
重新连接
</button>
<button
v-if="showClose"
type="button"
class="rounded-md border border-zinc-300 bg-white px-2 py-0.5 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
@click="emit('closePane', paneId)"
>
移除面板
</button>
</div>
</div>
</div>
</template>
@@ -16,6 +16,7 @@ const props = defineProps<{
const emit = defineEmits<{
split: [direction: 'row' | 'col'];
closePane: [paneId: string];
reconnectPane: [paneId: string];
toggleDark: [];
toggleRightWing: [mode: 'files' | 'detail'];
collapseStage: [];
@@ -123,6 +124,7 @@ const activePane = computed(() => {
<!-- 在最右栏展开与左栏等宽(336px)的「文件管理」 -->
<button
v-if="!standalone"
type="button"
class="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1 text-xs font-medium transition"
:class="
@@ -141,6 +143,7 @@ const activePane = computed(() => {
<!-- 在最右栏展开与左栏等宽(336px)的「详情图表」 -->
<button
v-if="!standalone"
type="button"
class="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1 text-xs font-medium transition"
:class="
@@ -201,6 +204,7 @@ const activePane = computed(() => {
:show-close="panes.length > 1"
@focus="focusedPaneId = pane.id"
@close-pane="emit('closePane', $event)"
@reconnect="emit('reconnectPane', $event)"
@meta-update="emit('metaUpdate', $event)"
/>
</div>
+71 -23
View File
@@ -52,6 +52,9 @@ pub struct AppState {
pub exec_tasks: Arc<ExecTaskManager>,
pub sessions: Mutex<HashSet<String>>,
pub port: u16,
/// 服务是否绑在本机回环。绑回环时 MCP 端点免 API Key(本机优先,对齐 1Shell);
/// 只有真正对外部署(HOST=0.0.0.0)时才强制校验 Key。
pub bind_is_loopback: bool,
}
pub fn build_api_router(state: Arc<AppState>) -> Router {
@@ -986,31 +989,75 @@ struct LocalRemoteTransferPayload {
remote_path: String,
}
/// UI 未指定本机落点时的默认路径:系统下载目录(Windows: %USERPROFILE%\Downloads),
/// 取不到就退回当前目录。文件名沿用远端文件名。
fn default_download_path(remote_path: &str) -> String {
let filename = remote_path
.rsplit(['/', '\\'])
.next()
.filter(|s| !s.is_empty())
.unwrap_or("download.bin");
let base = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(std::path::PathBuf::from)
.map(|home| home.join("Downloads"))
.filter(|p| p.is_dir())
.unwrap_or_else(|| std::path::PathBuf::from("."));
base.join(filename).to_string_lossy().to_string()
}
async fn api_create_local_remote_transfer(
State(state): State<Arc<AppState>>,
Json(p): Json<LocalRemoteTransferPayload>,
) -> impl IntoResponse {
match files::transfer_local_remote_file(
&state.ssh_pool,
&state.db,
&state.transfers,
&p.host_id,
&p.direction,
&p.local_path,
&p.remote_path,
)
.await
{
Ok(task) => (
StatusCode::OK,
Json(json!({
"ok": true,
"task": task,
})),
)
.into_response(),
Err(e) => err_response(StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
if p.direction != "upload" && p.direction != "download" {
return err_response(StatusCode::BAD_REQUEST, "direction 只能是 upload 或 download")
.into_response();
}
if state.db.get_record(&p.host_id).ok().flatten().is_none() {
return err_response(StatusCode::BAD_REQUEST, "主机不存在").into_response();
}
let local_path = if p.local_path.trim().is_empty() {
if p.direction == "download" {
default_download_path(&p.remote_path)
} else {
return err_response(StatusCode::BAD_REQUEST, "上传需要指定本机文件路径")
.into_response();
}
} else {
p.local_path.trim().to_string()
};
// 后台跑:大文件不能把这条 HTTP 请求挂住。进度、速率、续传与校验状态都走
// /api/transfers 队列查询(前端会轮询)。这里立刻返回本机落点供界面提示。
let st = state.clone();
let host_id = p.host_id.clone();
let direction = p.direction.clone();
let remote_path = p.remote_path.clone();
let local_for_task = local_path.clone();
tokio::spawn(async move {
let _ = files::transfer_local_remote_file(
&st.ssh_pool,
&st.db,
&st.transfers,
&host_id,
&direction,
&local_for_task,
&remote_path,
)
.await;
});
(
StatusCode::OK,
Json(json!({
"ok": true,
"started": true,
"localPath": local_path,
})),
)
.into_response()
}
async fn api_clear_transfers(State(state): State<Arc<AppState>>) -> impl IntoResponse {
@@ -1502,9 +1549,10 @@ async fn api_mcp_rpc(
return err_response(StatusCode::FORBIDDEN, "MCP Server 当前已在设置中关闭").into_response();
}
// API Key 一律校验。stdio 桥接同样走真实 Key(见 mcp.rs try_forward_to_daemon),
// 不再存在任何客户端可伪造的豁免请求头。
if settings.mcp_require_api_key {
// API Key 只在**非回环绑定**(真的对外部署)时才校验。绑在本机回环时,端点只有本机
// 进程够得着,和 1Shell 一样免校验——既符合"本机优先"的定位,也消除了"桥接进程与
// 守护进程解析到不同数据目录、key 对不上 → 401 → 永远握不上手"这类纯本机自伤。
if settings.mcp_require_api_key && !state.bind_is_loopback {
let provided = extract_mcp_api_key(&headers);
if provided.as_deref() != Some(settings.mcp_api_key.as_str()) {
return err_response(
+16 -4
View File
@@ -37,14 +37,26 @@ impl MasterKey {
let key_path = data_dir.join("secret.key");
if key_path.exists() {
let raw = fs::read_to_string(&key_path).context("读取 secret.key 失败")?;
let decoded = B64
.decode(raw.trim())
.context("解码 secret.key base64 失败")?;
if decoded.len() == 32 {
let trimmed = raw.trim();
if !trimmed.is_empty() {
let decoded = B64
.decode(trimmed)
.context("解码 secret.key base64 失败")?;
if decoded.len() != 32 {
// 关键:绝不覆盖。旧实现遇到非法内容会直接生成新密钥盖掉原文件,
// 那会让库里所有已加密的密码/私钥**永久**解不开,且毫无提示。
// 宁可拒绝启动,让人工处理(真要从头开始就手动删掉这个文件)。
return Err(anyhow!(
"secret.key 存在但不是合法的 32 字节密钥(解出 {} 字节),已拒绝覆盖,\
以免破坏库中已加密的凭据。请人工恢复该文件;确需重新开始请手动删除它。",
decoded.len()
));
}
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
return Ok(Self { key });
}
// 空文件(例如上一次写入被中途打断):当作尚未创建,走下面重新生成
}
let mut key = [0u8; 32];
+11 -3
View File
@@ -544,10 +544,14 @@ impl Database {
Ok(())
}
/// 记录一次检测结果。`status` 取 "online" | "offline" | "unknown":
/// - online:SSH 已连上(探针是否采到数据不影响这个结论);
/// - offline:确定连不上(连接被拒绝 / 认证失败);
/// - unknown:无法判定(连接超时这类——可能只是线路慢,不直接判死)。
pub fn update_check_result(
&self,
id: &str,
online: bool,
status: &str,
latency_ms: Option<i64>,
os_info: Option<OsInfo>,
error: Option<String>,
@@ -556,9 +560,13 @@ impl Database {
.get_record(id)?
.ok_or_else(|| anyhow!("主机不存在: {id}"))?;
let now = Utc::now().to_rfc3339();
let status = if online { "online" } else { "offline" };
let status = match status {
"online" => "online",
"offline" => "offline",
_ => "unknown",
};
let merged_os = os_info.or(existing.os_info);
let last_connected = if online {
let last_connected = if status == "online" {
Some(now.clone())
} else {
existing.last_connected_at
+12
View File
@@ -1350,6 +1350,18 @@ pub async fn transfer_local_remote_file(
return Err(anyhow!(msg));
}
None => {
// 远端算不出 SHA-256(没有 sha256sum/Get-FileHash)时,非续传的整文件下载
// 仍可接受(只是无法"宣称已校验");但**续传**的下载绝不能静默放行——
// 残片是不是这个源文件的前半段,唯一判据就是收尾这次对账,对不了账就
// 可能把旧残片拼进成品。宁可报失败,也不产出一个悄悄坏掉的文件。
if resume_from > 0 {
let msg = format!(
"本次从 {resume_from} 字节续传,但远端无法计算 SHA-256,无法确认残片与源文件一致;文件已保留,请人工核查后重传(或删除残片重下)"
);
transfers.fail_task(&task_id, &msg);
transfers.set_verification(&task_id, Some(digest), None);
return Err(anyhow!(msg));
}
transfers.set_verification(&task_id, Some(digest), None);
}
}
+33 -23
View File
@@ -211,6 +211,13 @@ fn summarize_probe_output(raw: &str) -> String {
}
}
/// 连接失败里区分"超时"与"被拒绝/认证失败"。
/// 超时只说明这次没连上、可能只是线路慢(慢 VPS 的 SSH 握手常超过 8 秒),不能据此判离线;
/// 被拒绝(Connection refused)或认证失败才是确定的"连不上"。
fn connect_error_is_timeout(msg: &str) -> bool {
msg.contains("超时") || msg.contains("deadline has elapsed") || msg.contains("timed out")
}
impl InspectorEngine {
pub fn new() -> Self {
Self::default()
@@ -243,23 +250,28 @@ impl InspectorEngine {
None => None,
};
let started = Instant::now();
let connect_started = Instant::now();
let session = match pool
.get_or_connect(&record, jump.as_ref(), db.master_key())
.await
{
Ok(s) => s,
Err(err) => {
let _ = db.update_check_result(
host_id,
false,
None,
None,
Some(format!("{err:#}")),
);
// 连不上要分两种情况:**超时**(不确定,可能只是线路慢)与**拒绝/认证失败**
// (确定连不上)。前者若也判离线,慢线路(zouter/lazy-* 这类)会长期误红,
// 所以记成 unknown 交给界面用灰灯表示"这次没判定出来",绝不冒充"离线"。
let msg = format!("{err:#}");
let status = if connect_error_is_timeout(&msg) {
"unknown"
} else {
"offline"
};
let _ = db.update_check_result(host_id, status, None, None, Some(msg));
return Err(err);
}
};
// 「SSH 响应」只算连接(握手+认证)耗时,不含后面的排队与探针——否则探针慢会把延迟撑到几十秒。
let latency_ms = connect_started.elapsed().as_millis() as i64;
let known_windows = record
.os_info
@@ -307,32 +319,30 @@ impl InspectorEngine {
break;
}
let latency_ms = started.elapsed().as_millis() as i64;
let (inspection, os_info) = match parsed {
Some(v) => v,
None => {
let detail = failures.join(";");
// 走到这里说明 SSH **已经连上**了(get_or_connect 成功)——这台机器是可连的。
// 探针跑不出来只代表"这次没采到数据",不该把灯判成离线:慢/抖线路上 channel
// 中途掉一次(例如回退的 Windows 探针返回 Disconnected)就会让一台能连的主机
// 常年标红(zouter 已实测复现)。所以保持在线,把采集失败原因写进 last_error。
if exec_errored {
// 连接层面出过错,池里这条连接不再可信
// exec 层面出过错,池里这条连接不再可信,回收它下次重建
pool.invalidate_host(host_id).await;
let _ =
db.update_check_result(host_id, false, None, None, Some(detail.clone()));
} else {
// SSH 通了、命令也跑了,只是两套脚本都认不出来:如实记成在线 + 错误原因,
// 绝不像旧版那样按 Linux 解析出一堆 0 再谎报采集成功。
let _ = db.update_check_result(
host_id,
true,
Some(latency_ms),
None,
Some(detail.clone()),
);
}
let _ = db.update_check_result(
host_id,
"online",
Some(latency_ms),
None,
Some(detail.clone()),
);
return Err(anyhow!("探针无法识别远端系统:{detail}"));
}
};
let _ = db.update_check_result(host_id, true, Some(latency_ms), Some(os_info), None);
let _ = db.update_check_result(host_id, "online", Some(latency_ms), Some(os_info), None);
self.latest_snapshots
.lock()
.unwrap()
+6
View File
@@ -194,6 +194,11 @@ fn main() -> Result<()> {
}
let exec_tasks = Arc::new(ExecTaskManager::new());
// 绑定地址在构造 AppState 前先算出来:MCP 端点是否免 Key 取决于它(绑回环 = 本机 = 免校验)
let bind_ip: std::net::IpAddr = std::env::var("HOST")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
let state = Arc::new(AppState {
db,
guard: Arc::new(Guard::load_or_create(&data_dir)?),
@@ -204,6 +209,7 @@ fn main() -> Result<()> {
exec_tasks: exec_tasks.clone(),
sessions: std::sync::Mutex::new(std::collections::HashSet::new()),
port,
bind_is_loopback: bind_ip.is_loopback(),
});
if is_mcp_stdio {
+49 -15
View File
@@ -5,7 +5,7 @@ use crate::{
guard::Verdict,
models::{HostRecord, LOCAL_HOST_ID},
ssh::open_host_channel,
terminal::looks_like_interactive_prompt,
terminal::looks_like_explicit_interactive_prompt,
};
use anyhow::{anyhow, Result};
use chrono::Utc;
@@ -257,7 +257,9 @@ impl ExecTaskManager {
return Ok(g.snapshot());
}
// 交互式提问早退保护:若命令输出末尾停在 [Y/n] / password: 等提问且静默超过 2 秒,立即中断提醒 Agent
// 交互式提问早退保护:只在命中**明确关键词**([Y/n] / password: / proceed? …)时才判定。
// 早先这里用宽松版("以 ? / : 结尾"也算),会把 `echo "Processing:"` 这类正常命令
// 在静默 2 秒后直接掐断并误报成 interactive_stall(已实测复现)。
if g.last_output_inst.elapsed() >= Duration::from_millis(2000)
&& (!g.stdout.is_empty() || !g.stderr.is_empty())
{
@@ -267,7 +269,7 @@ impl ExecTaskManager {
String::from_utf8_lossy(&g.stderr)
);
if let Some(last_line) = combined.lines().rev().find(|l| !l.trim().is_empty()) {
if looks_like_interactive_prompt(last_line) {
if looks_like_explicit_interactive_prompt(last_line) {
if let Some(tx) = g.cancel_tx.take() {
let _ = tx.send(());
}
@@ -1235,11 +1237,17 @@ async fn tool_vps_terminal(state: &Arc<AppState>, args: Value) -> Result<Value>
pane.send_and_wait(bytes, max_lines, wait_ms).await
}
"key" => {
let key_name = args
let raw_input = args
.get("input")
.and_then(|v| v.as_str())
.unwrap_or("enter")
.to_lowercase();
.unwrap_or("enter");
// 护栏:key 分支的兜底分支会把未知字符串原样当按键发出去,等于一条不受检查的文本
// 通道(`key:"rm -rf /"` 再 `key:"enter"` 就能执行)。这里和 send 一样先过黑名单。
// 真正的控制键(ctrl+c/enter/…)不会命中名单,照常发送。
if let Verdict::Deny { why } = state.guard.check(raw_input) {
return Err(anyhow!("{why}"));
}
let key_name = raw_input.to_lowercase();
let bytes: Vec<u8> = match key_name.as_str() {
"ctrl+c" | "c-c" | "sigint" => vec![0x03],
"ctrl+d" | "c-d" | "eof" => vec![0x04],
@@ -1520,13 +1528,24 @@ pub async fn run_stdio_bridge(state: Arc<AppState>, exec_tasks: Arc<ExecTaskMana
continue;
};
// 优先尝试转发给已运行的常驻 UI 主进程(以便共享 SshPool 与 UI 终端弹窗联动);
// 带上与主进程共库的真实 API Key,不再依赖任何可伪造的"我是内部桥接"请求头
let api_key = state
.db
.get_app_settings()
.map(|s| s.mcp_api_key)
.unwrap_or_default();
let settings = state.db.get_app_settings().ok();
// 尊重 MCP 总开关(否则下面的进程内回退会绕过它)
if settings.as_ref().map(|s| !s.mcp_enabled).unwrap_or(false) {
let err = json!({
"jsonrpc": "2.0",
"id": req.id,
"error": { "code": -32000, "message": "MCP Server 已在设置中关闭" }
});
println!("{}", serde_json::to_string(&err)?);
continue;
}
let api_key = settings.map(|s| s.mcp_api_key).unwrap_or_default();
// 优先转发给已运行的常驻主进程(共享 SshPool 与 UI 终端联动)。只有转发**确实拿到了
// 一个可用的 JSON-RPC 响应**才采用它;否则回退到进程内处理。
// 此前是"拿到任何响应就原样打印"——当两个进程解析到不同的库、API Key 对不上时,
// 服务端回 401,桥接把那段错误文本当结果吐给客户端,客户端解析失败 → 永远握不上手
// (WorkBuddy 连不上的根因)。现在 401/403 之类会返回 None 触发回退。
if let Some(resp_str) = try_forward_to_daemon(port, &api_key, trimmed).await {
if !resp_str.trim().is_empty() {
println!("{}", resp_str.trim());
@@ -1541,6 +1560,9 @@ pub async fn run_stdio_bridge(state: Arc<AppState>, exec_tasks: Arc<ExecTaskMana
Ok(())
}
/// 把一条请求转发给常驻主进程。返回 `Some(body)` 仅当对方回了一个可用的 JSON-RPC 响应
/// (HTTP 200 + 含 `jsonrpc` 字段),或 `Some("")` 表示通知类已被接收(202 无响应体)。
/// 其余情况(连不上、401/403、响应体不是 JSON-RPC)一律返回 `None`,让调用方回退到进程内处理。
async fn try_forward_to_daemon(port: u16, api_key: &str, raw_json: &str) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let addr = format!("127.0.0.1:{port}");
@@ -1561,8 +1583,20 @@ async fn try_forward_to_daemon(port: u16, api_key: &str, raw_json: &str) -> Opti
let mut resp = Vec::new();
stream.read_to_end(&mut resp).await.ok()?;
let resp_str = String::from_utf8_lossy(&resp);
let (_, body) = resp_str.split_once("\r\n\r\n")?;
Some(body.trim().to_string())
let (head, body) = resp_str.split_once("\r\n\r\n")?;
let status_line = head.lines().next().unwrap_or("");
if status_line.contains(" 202 ") {
// 通知类请求:主进程已接收、本就不产生响应体
return Some(String::new());
}
if !status_line.contains(" 200 ") {
return None;
}
let body = body.trim();
if !body.contains("\"jsonrpc\"") {
return None;
}
Some(body.to_string())
}
// ==================== 自动写入 Claude Code (~/.claude.json) 与 Codex (~/.codex/config.toml) ====================
+2 -2
View File
@@ -562,7 +562,7 @@ pub async fn open_host_channel(
let channel = pool
.open_session_channel(&record, jump.as_ref(), db.master_key())
.await?;
let _ = db.update_check_result(&record.id, true, None, None, None);
let _ = db.update_check_result(&record.id, "online", None, None, None);
Ok((record, channel))
}
@@ -595,6 +595,6 @@ pub async fn open_pane_channel(
.context("打开 SSH 会话通道失败")?
}
};
let _ = db.update_check_result(&record.id, true, None, None, None);
let _ = db.update_check_result(&record.id, "online", None, None, None);
Ok((record, channel))
}
+30 -2
View File
@@ -414,7 +414,10 @@ fn strip_terminal_queries(data: &[u8]) -> Vec<u8> {
out
}
pub fn looks_like_interactive_prompt(line: &str) -> bool {
/// 严格版:只认明确的关键词(password: / [y/n] / proceed? …)。
/// 用于非交互 exec 的早退判定——那里没有 stdin,误判会直接掐断一条正常命令,
/// 所以宁可漏判,也不能凭"以 ? / : 结尾"这种文本特征去猜(`Processing:` 就会被误伤)。
pub fn looks_like_explicit_interactive_prompt(line: &str) -> bool {
let lower = line.trim().to_lowercase();
if lower.is_empty() {
return false;
@@ -436,9 +439,20 @@ pub fn looks_like_interactive_prompt(line: &str) -> bool {
"请确认",
"请输入",
];
if patterns.iter().any(|p| lower.contains(p)) {
patterns.iter().any(|p| lower.contains(p))
}
/// 宽松版:明确关键词,或"以 ? / : 结尾且不像 shell 提示符"。
/// 只用于交互式终端(vps_terminal)的屏幕状态判定——那里本来就在等用户输入,
/// 多报一次 waiting_input 无伤大雅;非交互 exec 请用严格版。
pub fn looks_like_interactive_prompt(line: &str) -> bool {
if looks_like_explicit_interactive_prompt(line) {
return true;
}
let lower = line.trim().to_lowercase();
if lower.is_empty() {
return false;
}
(lower.ends_with('?') || lower.ends_with(':')) && !looks_like_shell_prompt(line)
}
@@ -590,6 +604,10 @@ impl PaneHandle {
pub struct TerminalManager {
panes: Mutex<HashMap<String, Arc<PaneHandle>>>,
ui_events_tx: broadcast::Sender<UiEvent>,
/// 每主机一把建 pane 的异步锁:串行化"检查有无活 pane → 释放旧连接 → 建新 pane"这段。
/// 否则两个并发 create(例如 agent 同时调 vps_terminal)会让后者的 drop_pane_session
/// 把前者刚建好的连接断掉,先建的那个 pane 立刻死。
create_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
}
impl Default for TerminalManager {
@@ -604,6 +622,7 @@ impl TerminalManager {
Self {
panes: Mutex::new(HashMap::new()),
ui_events_tx,
create_locks: Mutex::new(HashMap::new()),
}
}
@@ -700,6 +719,15 @@ impl TerminalManager {
let rows = rows.clamp(5, 200);
let pane_id = format!("pane_{}", Uuid::new_v4());
// 串行化同一主机的并发建 pane(见 create_locks 注释)
let host_lock = {
let mut g = self.create_locks.lock().unwrap();
g.entry(host_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
let _create_guard = host_lock.lock().await;
// 先回收该主机已退出的旧 pane。一个活的都不剩 = 上个终端已经关完了:
// 释放终端连接,新 pane 会重新登录,登录横幅(MOTD)才会再次出现。
let has_live = {