Skip to content
| Marketplace
Sign in
Visual Studio Code>Other>BastionShell SSHNew to Visual Studio Code? Get it now.
BastionShell SSH

BastionShell SSH

BastionShell

|
44 installs
| (1) | Free
SSH 堡垒机终端:一次 MFA + 连接复用,多开会话/批量部署/rz/sz 文件传输/端口转发/快捷命令,并提供 bastion.exec 让 VS Code 里的 AI 插件在服务器上执行命令
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

BastionShell SSH(VS Code 扩展)

CI license last commit

VS Code TypeScript ssh2 ZMODEM 菜单识别

仓库:https://github.com/xingzitian/bastion-vscode · 商店:https://marketplace.visualstudio.com/items?itemName=xingzitian.bastion-vscode · 问题反馈:https://github.com/xingzitian/bastion-vscode/issues

⚠️ 本扩展是功能最全的版本。 同一个产品的桌面版(Wails 原生窗口, https://github.com/xingzitian/bastion-shell)是另一套独立实现(Go + React): 它已经有了会话登记表、命令执行引擎、MCP 出口(12 个工具,含传文件与连新机器)、高危拦截与个人习惯, 并且菜单规则 / 高危规则 / 个人习惯三个文件与本扩展读的是同一份(~/.bastionshell/*.jsonc); 协议上已跟到 2026-07-28(discover / Tasks / MRTR / 作业日志落本机),但没有部署报告。 两边到底各有什么,见 两个版本的能力对照。

站在 VS Code 的肩膀上的堡垒机终端:一次 MFA 认证 + 连接复用,之后多开会话、批量部署、文件上传都免二次认证。

装 & 用(三步)

  1. 装:扩展商店搜 BastionShell SSH,或 code --install-extension bastion-vscode-0.3.11.vsix
  2. 连:命令面板 → BastionShell: 连接(输密码 + 动态码;堡垒机则过菜单选目标机)
  3. 用:终端里照常敲命令;要传文件就 rz/sz;要部署就用「部署任务」;要让 AI 干活就跑一次 BastionShell: 让外部 AI 连上(MCP 端点 / 一键复制配置)

本 README 怎么读(按你现在的角色挑一列):

我是… 读这几节
普通使用者 核心能力 · 状态栏 · 传输进度与历史 · 快捷命令 · 只读模式 · 广播输入 · 端口转发
要接堡垒机 / 生产机 直连 vs 堡垒机 · 堡垒机菜单识别(含资产 ID) · 高危命令 · 面向不同厂商的识别规则
要让 AI 干活 🔌 让外部 AI 用你的会话 · 🖥 会话绑定 · ⏳ 长命令用 wait · 🧪 自检 · 📋 协议支持 · AI 桥接
二次开发 / 排障 配置 · 部署任务 JSON 模板 · 开发与测试 · 代码结构

只想知道「命令怎么配」:看 配置 一节;只想查某个命令 ID:看 命令 ID 一节。

核心能力

  • 连接复用:一条堡垒机连接认证一次(密码 + MFA),后续开 N 个 shell 会话直接复用,不再输 MFA。
  • MFA 在终端内输入:请求 MFA 时在终端里直接敲动态码(带倒计时),不弹窗。
  • rz/sz 文件传输:ZMODEM(zmodem.js)上传/下载,适配 lrzsz。实时进度条(速率 + 剩余时间),覆盖方式可配置,传完自动记进「传输历史」。
  • 部署任务:文件式任务(JSON),批量连接目标机器 → 前置命令 → 上传 → 脚本。带逐台进度和结果报告。
  • 快捷命令、端口转发、批量连接。
  • 只读模式:一键把会话切成「只看不敲」,生产环境防手滑。
  • 广播输入:勾几台会话,之后在任意一个会话里敲的命令会同时发给它们 —— 同一个改动要在几台机器上各敲一遍时用(开着时底栏有警告色的「广播」格子,忘记关是危险的,所以必须看得见)。
  • 个人习惯:把「你在这台机器上怎么干活」记成文件(提权方式 / 常用目录 / 口头习惯),AI 读一次就不再重复问你。
  • 选中内容一键发给 AI:终端里选中文字后按键直接送进 AI 聊天,和编辑器里选代码发给 AI 一个手感。
  • AI 桥接:bastion.exec 命令让运行在 VS Code 里的 AI 插件能在服务器上执行命令(详见下文)。

状态栏

底栏分「常驻」和「临时」两层,临时项只在有事时出现、结束就消失,不会互相抢位。

位置 显示 点击
$(terminal-bash) Bastion N 会话 常驻:当前几个会话;焦点不在终端时变黄。悬停可看每个会话的编号、连接状态、已连时长、复用情况 弹会话列表切换(含「新建连接」)
$(unlock) 可写 / $(lock) 只读 常驻(有会话时):当前会话是否只读 切换只读模式
$(broadcast) 广播 2 台 开着广播才出现:当前广播给几个会话。悬停列出是哪几台 重新选择 / 全部取消即关闭
$(cloud-upload) 同名:跳过 常驻(有会话时):上传覆盖方式 切换 跳过 / 覆盖 / 改名
$(radio-tower) 转发 2 条 有隧道在跑。悬停列出每条隧道的端口映射 打开端口转发视图(不是停止!)
$(cloud-upload) ↑ 文件 43% · 1.2 MB/s · 剩 3s 传输进行中 打开传输历史
$(sync~spin) 部署 3/8 部署 / 批量连接进行中 —
$(key) MFA 剩余 23s ███████░░░ 正在等 MFA 输入 —

传输/部署结束时会在同一位置闪一条回执(成功 $(check)、失败 $(error) 且标红),几秒后自动消失。

破坏性操作不放状态栏。 之前「转发」那一条点一下就全部停止,很容易误触。「全部停止」已挪到侧边栏「端口转发」视图的标题栏(有确认弹窗),状态栏那一条只负责打开视图。

会话号与连接状态

每个会话创建时分配一个自增编号,终端名形如 #1 deploy@10.0.0.10(可用 bastion.sessionNumber 关掉)。

  • 状态栏「Bastion N 会话」悬停能看到按底层连接分组的完整信息:

    BastionShell 会话(2 个)
    
    生产 · deploy@10.0.0.10 · 🟢 已认证 · 已连 12m · 复用 2 会话
    - #1 deploy@10.0.0.10  可写 · 已连 12m ← 当前
    - #2 deploy@10.0.0.10  只读 · 已连 3m
    
  • 点会话列表也能看到编号、所属档案、当前活动项。

  • 连接复用一眼可见:一条 SSH 连接认证一次,复用了几个会话。

  • 断开的连接会显示 ⛔ 已断开,只读会话标 只读。

侧边栏视图

左侧活动栏点终端图标,容器里有五个分区。每个视图的条目都遵循同一套三层约定,不用猜「点一下会发生什么」:

视图 单击(= 主操作) 行内按钮 右键菜单
连接档案 连接 ● 连接 连接 / 编辑档案 / 删除档案
部署任务 打开任务文件编辑 ▶ 运行 · ■ 停止 运行·停止 / 打开编辑 / 删除
快捷命令 发送到当前会话 ▶ 发送 发送 / 编辑命令 / 删除命令
端口转发 启动 / 停止切换 ▶ 启动 · ■ 停止 启动·停止 / 编辑规则 / 删除规则
传输历史 (不执行,只读记录) ↻ 重试(失败时) 重试 / 在文件夹中显示 / 复制本地路径

约定说明:

  • 右键菜单固定三组:主操作 → 编辑/操作 → 删除,顺序在每个视图里都一致。行内按钮只放主操作。
  • 每个条目的 tooltip 第一行都写明「单击:…」,悬停即可确认,不用试探。
  • 部署任务的单击刻意是「打开文件」而不是「运行」:跑一次部署会改远端,不该由一次误点触发。 要运行就点行内 ▶ 或右键首项 —— 这两种入口都是明确的动作。其余视图的单击是动作,因为那是它们唯一的用途。
  • 运行中的部署任务:图标转圈、描述带 运行中 3/8、行内按钮变 ■(停止)。 停止粒度是「跑完当前这台就停」—— 中途硬中断会把目标机留在半执行状态,比多等一台危险。 剩下没跑的会在报告里标成「⏹ 未执行(已停止)」。

这套约定有测试守着(src/test/manifest.test.ts):每个视图必须同时具备行内主操作、右键 1_主操作/2_*/3_删除 三组, 菜单引用的命令必须真的存在,拼错视图 id 会直接测试失败。

总览与进度面板(右侧停靠)

底栏那些格子只能靠悬停看详情,而悬停本身就难用(要停在原位、一动就消失、里面什么也点不了)。 所以做了一个固定在右侧的「总览与进度」面板 —— 点底栏任意一个 Bastion 格子就会展开它:

会话 (2)
┌────────────────────────────────────────┐
│ #1 deploy@10.0.0.10  [已认证][当前] │
│ 堡垒机 · 已连 12m · 复用 2 会话              │
│ [已在前面] [设为只读] [关闭]                 │
└────────────────────────────────────────┘

传输 · 进行中 1
┌────────────────────────────────────────┐
│ ↑ 上传 bundle.tar.gz              [43%]  │
│ 41.0 MB / 95.5 MB · 3.4 MB/s · 剩 16s    │
│ ███████████░░░░░░░░░░░░░░░░░░░░░        │
└────────────────────────────────────────┘

部署
┌────────────────────────────────────────┐
│ 部署 myapp 配置                    [3/8]  │
│ 已用 2m14s                                │
│ ████████░░░░░░░░░░░░░░░░░░░░░░░░        │
│ [停止(跑完当前这台)]                    │
└────────────────────────────────────────┘

端口转发 (2) / 快捷操作:新建连接 · 切换会话 · 上传覆盖 · 传输历史 · 个人习惯 · 停止全部转发 · 打开日志
  • 实时:面板可见时每秒刷新,所以速率、剩余时间、已连时长这些数字是动的。
  • 可直接操作:每个会话有「聚焦 / 设为只读 / 关闭」,部署有「停止」,转发有「停止」,底部还有一排快捷操作。 底栏格子原来点击时的那些动作(切换会话、切换覆盖方式、停止转发…)全部搬进了面板,功能没丢。
  • 原来底栏点击各自干不同的事(有的切换会话、有的打开视图),现在统一成「打开这个面板」—— 和上一节说的「单击语义一致」是同一条原则。

为什么是「右侧停靠」而不是「右下角悬浮小窗」

VS Code 没有创建悬浮窗口的 API —— 没有任何接口能让扩展在工作台右下角浮一个始终置顶的小窗。 能做到「固定 + 可交互 + 有进度条」的只有停靠式视图,可选容器有三个:左侧活动栏、底部面板区、右侧辅助侧边栏。

选右侧辅助侧边栏的理由:底部面板要留给终端 —— 堡垒机操作基本都盯着终端,放右侧才能两者兼顾。 (VS Code 允许把这个视图拖到别的容器里,它会记住你的选择。)

传输进度与历史

上传/下载时底栏实时显示进度、平均速率和剩余时间,不用盯着终端猜还要多久。传完写进「传输历史」视图,失败的上传右键就能重试(会把原文件再传一遍)。

传文件只有一套说法(标准工具)

不管从哪进(右键上传、粘本地路径、部署任务、传输历史重试、AI 的 bastion_push / bastion_pull), 底层都是同一个传输工具,所以规则完全一致:

你要传的 走哪条 备注
单个文件 rz 目标机要装 lrzsz(yum install -y lrzsz / apt-get install -y lrzsz)
目录 本地 tar 打包 → 传 → 远端自动解开 → 删临时包 以前只会拒绝目录
没有 lrzsz 的目标机 自动降级 base64(上传分块塞进 shell / 下载打出来本地解码) 不用装任何东西;超过 4 MB 会明确拒绝(那条路不划算)
  • 覆盖方式只有三个人话词:跳过(默认)/ 覆盖 / 改名 —— 由 bastion.uploadOverwrite 决定。 (rz -y、rz -E 是实现细节,不会出现在任何提示里。)
  • 传完会回读远端给你证据:远端绝对路径 + 内容哈希 + 大小。 哈希一致才说"✅ 已上传";对不上就说"❌ 上传没成功"。 不要再用 ls / md5sum 自己验一遍 —— 远端本来就有同名旧文件时,那样会验出假成功。
  • 会先探测目标机有什么(rz/sz/base64/tar/md5sum,每个会话探一次并缓存), "用了哪条通道、为什么降级"都写进日志(搜 [transfer])。

ZMODEM 会保留源文件的 mtime:传过去的文件,远端 mtime 等于本机源文件的 mtime。 所以远端 ls -l 看到的时间"很旧"是正常的(那正是你本机那份的时间),不代表没传上去 —— 我们一度按 mtime 判断上传成功,结果每次都误报,这条已经写进代码注释和测试里了。

把本地文件路径粘/拖进终端也能传:在终端里回车,如果这一整行是一个存在的本机绝对路径(D:\...、\\server\share\...、WSL 的 /mnt/d/...),会问一句「上传到当前目录吗」,而不是把路径当命令发出去。 相对路径(Makefile、./deploy.sh)和裸 /tmp/x 故意不认 —— 那些在远端同样常见,认错了会误拦你的命令。 看起来正在输入本机路径的内容会先扣住不发,所以不会出现「远端先报一句 No such file or directory,然后才问你要不要上传」。

用 rsync 增量同步(文件和目录都能传)实验性 · 未完成

🚧 这个功能还没做完,默认是关掉的,需要手动打开设置 bastion.rsyncEnabled 才会出现菜单项。

已知问题:在本机测试台(真 SSH + 真 pty + 真 rsync 客户端,两个 rsync 协议版本、含 512MB 大文件) 全部通过;但在某类堡垒机上,大文件传到 ~95%(最后几 MB)会停住 —— 链路本身很快(实测 4MB/s,22 秒传了 87MB),最后几 MB 堵在管道里、远端也不再回数据。 同一个文件走 rz 一直正常(rz 是「发一块等一个确认」的小窗口,对堡垒机代理层的压力和 rsync 这种持续大流量完全不同)。目前定位不到是链路哪一层吃掉了它 —— 怀疑在堡垒机代理对 pty 上持续双向流量的处理上。

欢迎高手来查:复现/定位用的探针脚本都提交在仓库里 —— tools/rsync-e2e(本地端到端测试台), 以及 desktop 仓库的 tools/rsync-probe/(当时用来把问题一段段二分出来的探针,含 RESULTS.md 记录)。 有结论请开 issue,附上 bastion 日志里那几行每 3 秒一次的 C2R/R2C 字节数日志 —— 它正好能看出是 "客户端不再发" 还是 "远端不再收"。

资源管理器里右键文件或目录 → 「用 rsync 增量同步到当前会话」(命令 bastion.rsyncSyncToSession)。

  • 块级增量:传过一次的内容不会重传,反复同步一个目录很快。
  • 能整棵目录同步:rz 只能一个一个文件传。
  • 目标机不需要 lrzsz,只要装了 rsync(rz 那条路要求目标机装 lrzsz,装不了就完全传不了)。
  • 会问你目标机目录:填 . = 目标机当前目录(会话停在哪儿就传到哪儿),也可以填 /opt/app 这种绝对路径;上次填的会记住。
  • 多选可以一起传(右键选中多个文件/目录)。目录的语义是「把目录里的内容同步到目标目录」(rsync 的 dir/ 语义)。
  • 同步中有实时进度(已发送 / 百分比 / 速率 / 已用),完成后会告诉你「本次实际发送了多少」—— 这样能直接看出增量省了多少。
  • 卡住不是死路:连续 30 秒没有任何字节流动会提示,并给「断开并续传」按钮。因为一直带着 --partial, 目标机上保留已传的部分,续传只补差的那一段。

⚠️ 打包格式会决定 rsync 有没有用:

  • xxx.tar.gz(压缩包):改一个字节整包重排 → rsync 找不到相同块 → 每次全量重传,白搭;
  • xxx.tar(不压缩):改动点之后的内容虽然错位,但 rsync 的滚动校验能认出来 → 只传变化的部分;
  • 直接同步目录(右键那个文件夹):最好 —— 每个文件独立比对,只有真变了的才传;
  • 或者 tar + gzip --rsyncable(让压缩块对齐,rsync 才认得出)。

怎么穿得过堡垒机:复用当前那条已经认证、已经选完目标机的 shell 会话,在里面注入 rsync --server(不开新通道 —— 也正因为如此,它比 SFTP 更能穿过堡垒机)。本机需要 rsync:Windows 默认找 %LOCALAPPDATA%\rsync\rsync.exe,也可以用 bastion.rsyncPath 指定。

这条路的两条关键细节是实测得来的(探针记录见 bastion-shell/tools/rsync-probe):pty 上协议起点前会有 10 字节终端噪声(\x1b[?2004l\r\n,bash 关括号粘贴时吐的),必须按握手形状找到协议起点再转发;远端结束后还必须把 EOF 传下去、并让桥的 shim 退出,否则 rsync 会和自己的子进程互等、永远不退出。这两条都有单元测试守着(src/test/rsyncBridge.test.ts)。

覆盖方式由 bastion.uploadOverwrite 控制,默认 skip(远端有同名文件就跳过,最安全)。两种改法:

  • 点状态栏 $(cloud-upload) 同名:跳过 → 弹出「跳过 / 覆盖 / 改名」直接选(有会话时才显示)
  • 设置里搜 bastion.uploadOverwrite
取值 行为 对应命令
skip 跳过,不覆盖 rz
overwrite 直接覆盖 rz -y
rename 远端自动改名,旧文件保留 rz -E

下载(sz)的落盘位置仍由每次的目录选择决定,不受这个设置影响。

快捷命令(quickCommands.jsonc)

快捷命令本来就是配置文件驱动的,命令写在这个文件里、保存即生效(改了保存侧边栏自动刷新),不用在界面上一条条点:

  • 侧边栏「快捷命令」标题栏的铅笔图标
  • 或命令面板搜「打开快捷命令配置文件」
  • 或用 ~/.bastionshell/quickCommands.jsonc 直接编辑

命令长了、或者你想自己维护,command 有三种写法:

[
  // 1) 短命令:直接写字符串
  { "id": "qc-disk", "label": "看磁盘", "command": "df -h" },

  // 2) 多行命令:写成数组,一行一个元素,不用写 \n
  {
    "id": "qc-multiline",
    "label": "看日志",
    "command": ["cd /data/logs", "tail -n 50 app.log", "grep -i error app.log | tail"]
  },

  // 3) 长脚本:写到真正的 .sh 文件里,这里只填路径
  { "id": "qc-restart", "label": "重启服务", "file": "D:/work/scripts/restart.sh" },

  // 可选:description 显示说明;sendEnter:false 只打进终端,你自己确认再回车
  { "id": "qc-careful", "label": "危险操作", "command": "rm -rf /tmp/x", "sendEnter": false }
]
  • file 支持绝对路径;相对路径按工作区根目录解析。文件不存在会明确告诉你缺哪个文件(不会静默不发)。
  • 某一条字段写错只会跳过那一条,不会连累整份列表(以前会把整个文件备份成 .bak 并清空列表,已修)。
  • 文件不存在时首次打开会生成带中文说明和三个示例的模板。

只读模式(防手滑)

命令面板搜「BastionShell: 切换只读模式」,或点底栏的 $(unlock) 可写 / $(lock) 只读,或在终端里右键选「切换只读模式」。

  • 开启后人工键盘输入全部被拦截,查看和复制不受影响;你想敲字时会提示一次(限流,不刷屏)。
  • 不影响 MFA 输入、AI 的 bastion_exec、部署任务注入 —— 拦的只是人手打字。
  • 逃生方式:命令面板那个命令随时能关,或点状态栏;实在卡住关掉终端即可。

广播输入(多会话同步)

三种开启方式:命令面板搜「BastionShell: 广播输入」、点底栏的 $(broadcast) 广播 N 台,或在右侧「总览与进度」面板里逐台点「加入广播」(改一台不用再过一遍列表)。

两种模式(底栏广播开着时会多出一格,点一下就能切)

模式 同步什么 什么时候用
原样同步(默认) 你敲的每一个键都镜像过去 在 vim 里改配置文件、走交互式菜单、用方向键翻历史 —— 这些都不是「一行」,只有逐键镜像才做得到
整行发送 只在按回车那一刻把这一整行发过去 给几台机器各敲同一条命令:敲到一半的内容、退格、方向键都不会打扰对面;空回车也不同步

切模式有三个入口:底栏那一格「原样同步 / 整行发送」(点一下切)、命令面板搜「广播模式」、右侧总览面板里的「广播模式:…」按钮。设置项是 bastion.broadcastMode。

为什么默认是原样同步:这是桌面版一直以来的行为。用户实测里最需要广播的场合就是「同一份配置要在几台机器上都改一遍」—— 那必须在 vim 里逐键同步。

边界

  • 只同步人工键盘输入。MFA 动态码、AI 的 bastion_exec、部署任务注入都不会被广播 —— 前者的码每台可能不同,后者是脚本化操作,不该被顺手复制到别的机器。
  • 只读会话不进列表(它连人工输入都拦),已关闭的会话会自动从广播目标里摘掉。
  • 取消全部勾选 = 关闭广播,不需要再记一个「关闭」命令。Esc 取消 = 什么都不改(这个命令也用来「看一眼现在广播到哪几台」)。
  • 上传不广播:广播中上传一个文件不会被复制到其它机器(每台都要各传一次,悄悄替别人传是危险行为)。
  • 至少要有两个可写会话才能开。

开着广播是有风险的:在测试机上敲的 rm 会在生产机上一字不差地跑一遍。所以它不是「设一下就不管」的偏好项 —— 开着的时候底栏常驻两格(警告色的「广播 N 台」+ 当前模式),总览面板里对应的会话卡片也会挂「广播中」标记,点一下就能改或关。

端口转发

在「端口转发」视图里:

  • 每条规则右侧有行内按钮:未启动显示 ▶(启动),运行中显示 ■(停止)—— 单独控制,不用右键。
  • 标题栏的 ■ 是「全部停止」,会先弹确认并列出要停的隧道。
  • 悬停任一条能看到本地监听、转发目标、所属档案、运行状态。
  • 状态栏的 转发 N 条 只用来看和跳到这个视图。

部署报告

每个任务跑完会生成一份 Markdown 报告(存在扩展目录 reports/,不混进你的项目)。报告的主体是每条命令的实际输出,不是一张没用的耗时表:

### 10.0.0.10 · ✅ 成功 · 8.4s

**前置命令** `cd /etc` · 0.1s
_(无输出)_

**脚本** `systemctl restart myapp` · 2.4s
```sh
Job for myapp.service failed because the control process exited with error code.
See "systemctl status myapp.service" and "journalctl -xe" for details.

- **逐行执行**:`preCommand` / `script` 按行拆开一条条跑,所以「哪条命令产生了什么输出」能对应上。整段发进去只能得到一坨混在一起的输出,事后没法复盘。
- **交互式命令**(`sudo -i`、`su`、后台任务)哨兵标记打不出来,输出无法可靠归属 —— 报告里会明确标出来并告诉你怎么去看现场,而不是假装有输出。
- **没等到结束标记的命令会明确警告**:说明它「很可能没有真正执行」,并点出最常见的原因(上一步的文件上传刚结束、远端的 `rz` 还没退出)。见下面「上传之后的那一步为什么容易丢」。
- 输出超过 4000 字符会截断并注明(避免报告变成巨型日志);输出里带 ` ``` ` 会自动换成更长的围栏,不让 markdown 破掉。
- **全成功**只弹一条带「打开报告」按钮的提示;**有失败就自动打开报告**。

### 上传之后的那一步为什么容易丢

**一句话:上传完要等提示符回来,再发下一条命令。** 就这一件事:

上传结束 → 等远端提示符(最多 3 秒,通常 100ms 就回来)→ 再发脚本


为什么需要这步:zmodem 协议结束(本地 `close()` 返回)**不等于**远端 `rz` 已经退出。
那个窗口里发过去的命令会被 `rz` 当成协议数据吃掉 —— 命令根本没执行,而我们只看到
「3 秒没输出」。它是**掷硬币**,同一台机器 10 次里 5 次丢:

| 那次上传 | 紧接着的 `cat README.md` |
|---|---|
| 成功(0.3s) | 0.1s,捕获到全文 ✅ |
| 成功(0.5s) | 3.1s,只捕获到一个提示符 ❌ |
| **被对端跳过** | 3.0s,只捕获到一个提示符 ❌ |

丢的那几次耗时都是 3.0~3.1 秒,正好是「输出静止 3 秒」的兜底 —— 说明结束标记
从未上线。而捕获输出是直接挂在 SSH 原始数据流上的(不经过 zmodem 那一层),
所以这**不是「日志没打印」**:那 3 秒里原始数据流上确实什么都没有。

> 报告里对这种情况会明确标注「⚠️ 没等到这条命令的结束标记 —— 它很可能没有真正执行」,
> 免得一个空输出看起来像「脚本跑了但没打印东西」。

批量执行多个任务时,最后合并成一份报告。

**部署结束后的会话终端怎么处理**由 `bastion.deployTerminalPolicy` 控制:

| 取值 | 行为 |
|---|---|
| `closeSuccess` | **成功的关掉,失败的留着**(默认)—— 失败终端里有出错现场,关掉等于销毁证据 |
| `ask` | 跑完弹一次问你要不要保留窗口(报告里有输出,但有人就是想自己去翻) |
| `keep` | 全部保留(长时间批量部署会堆一堆终端) |
| `closeAll` | 全部关掉,包括失败的 |

不想翻设置的话,有个**一键开关**(`bastion.toggleKeepDeployTerminal`):

- **底栏会常驻一格「$(terminal) 保留终端」**,只要它是开着的就一直看得见,点一下就关
- 命令面板搜「部署结束保留终端窗口」
- 「总览与进度」面板的「快捷操作」里也有一个按钮,直接显示当前是开还是关
- 「部署任务」视图标题栏也有一个图标

> 这个开关是补一个真实痛点:以前 `keep` 只存在于设置里,用户找不到,于是每次都看着终端
> 被自动关掉、报告又是空的,还以为「任务成功 = 没输出」。排查问题时最需要的恰恰是那个
> 窗口里的原始输出,所以它必须**看得见、点得到**。

## 直连 vs 堡垒机

两种档案在**执行任务时走的是完全不同的路径**,直连省掉两个步骤:

| | 直连档案 | 堡垒机档案 |
|---|---|---|
| 目标机 | **就是档案自己那台**,`hosts` 不用填(填了也忽略) | 必须在任务 JSON 的 `hosts` 里列出来 |
| 选目标机 | 无 | 过菜单输 IP(等提示文本,认得就快) |
| 选用户 | 无 | 过菜单选序号(`userChoice`;管理员账号没有这一步会自动跳过) |
| 跑几台 | 1 台(档案本身) | `hosts` 里几台就几台 |
| 报告里标注 | 「直连(目标机就是档案本身)」 | 「堡垒机,选用户序号 N」 |

批量连接(`bastion.batchConnect`)同理:选到直连档案就**跳过主机列表和选用户**,直接开一个会话。

> 以前直连也走堡垒机菜单那套,于是白等一个永远不出现的选机菜单、还把档案里的 host 当成目标机 IP 打进已经进去的 shell;更糟的是 `hosts` 为空时整个任务**静默什么都不做**(报告显示 0/0)。现在 `hosts` 为空会直接报错说明原因。

## 堡垒机菜单识别

连接目标机时要过堡垒机的选机/选用户菜单。以前这步是**固定等待**(等 800ms → 输 IP → 等 1500ms → 选用户 → 等 2000ms),网络慢一拍就错位:IP 打进一半、用户序号发给了还没画完的菜单。而这条路径是 AI 连接、批量部署、批量连接**共用**的。

现在改成**认屏幕**:等这一屏画完(输出静止约 0.7~0.9 秒)→ 读屏幕上的文字 → 认出提示就立刻走;认不出来再退回固定等待。所以:

- 正常情况比原来快 3 秒左右(主菜单实测 ~400ms 就命中)
- 菜单画得慢也不会错位(输完 IP 后耐心等到 20 秒)
- **认不出来时退化成改造前的行为**,不会更差
- 分几段画屏(先表头、再表格、再提示)会**多等几轮**,不会「画了一半」就判没认出来

### 为什么是「读屏幕」而不是「在数据流里找提示」

踩过两次同一个坑之后才定下来的:

1. **转义序列被 TCP 切碎**:`\x1b[32m` 这种颜色码经常被拆到两个网络包里,逐块清理会留下 `输\x1b[32m入` 这种残渣,提示文本就再也匹配不上。
2. **一屏一屏刷**:堡垒机画菜单时夹着 `\r` 回行覆盖和光标移动,同一个位置在数据流里会叠好几遍,跟屏幕上真正显示的不是一回事。

所以现在缓冲里存**原始数据**,识别时对**整段**做「去 ANSI + `\r` 只留最后一段」,拿到的就是屏幕上看到的文字(等于日志里 dump 出来的那份干净原文)。另外每次操作**先打一个屏幕标记**,只看标记之后画出来的画面 —— 否则滚动缓冲里的旧菜单会让「IP 没被接受」误判。

### 有的账号没有「选用户」这一步

**这是个真问题,不是慢一点而已。** 有些管理员账号输完目标机 IP 就**直接进 shell**,根本不弹选用户菜单。旧实现会:

1. 傻等选用户菜单 **8 秒**超时,再兜底等 2 秒 —— 白等 10 秒
2. 然后把用户序号(默认 `1`)**打进已经进去的 shell 里** —— 等于执行了一条叫 `1` 的命令
3. 后面的前置命令、脚本全部错位

现在输完 IP 之后是**同时等四种界面**,按先到的走:

| 先等到 | 判定 | 动作 |
|---|---|---|
| **资产列表**(一个 IP 匹配到多条) | 要先选**资产 ID** | 只匹配到一条 → 自动选;多条 → **弹窗让你选**(AI 也可以直接传 `assetId`),选完再继续 |
| 「选登录用户」菜单 | 普通账号 | 发 userChoice,再等 shell |
| shell 提示符 | **管理员账号,没有这一步** | **跳过选用户,直接开始干活** |
| 又回到「输目标机」 | IP 没被接受 | **直接报错说明原因**,不再硬着头皮往下发 |
| 都超时 | 认不出来 | 按旧行为兜底发 userChoice(并在日志里告诉你怎么调) |

### 一个 IP 匹配到多条资产(资产 ID)

同一个 IP 在堡垒机里可能是**两台不同的机器** —— 比如既有一台 Linux 本体、又有一台 Gateway 网关。
这时堡垒机不会直接登录,而是列一张表让你输**资产 ID**:

ID | 名称 | 地址 | 平台 | 组织 | 备注 1 | 172.20.30.143 | 172.20.30.143 | Linux | 默认组织 | 2 | 研发网域172.20.30.143 | 172.20.30.143 | Gateway | 默认组织 | 提示:输入资产ID直接登录,二级搜索使用 // + 字段,如://192


程序会**把这张表解析出来**,然后三选一(**绝不会盲发一个数字** —— 那可能把命令执行到错的机器上):

- **指定了 `assetId`** → 直接用(AI 调用时传,或部署任务里写 `"assetId": "2"`);
- **只匹配到一条** → 自动选它(人也是这么做的);
- **匹配到多条** → **弹窗列出「ID · 名称 · 地址 · 平台 · 组织」让你挑**;你按 Esc 取消时,AI 会收到一条带候选列表和「带 assetId 重试」提示的消息。

连接成功后 AI 也会被告知**这次登录的是哪一条、还有哪些候选**(多条同名资产时,这是唯一能看出差别的地方)。

> 以前这一屏会被主菜单规则误命中(那句「输入资产ID直接登录」里有「输入…资产」),
> 于是被判成「又回到输 IP」→ 报「这个 IP 没被堡垒机接受」。**AI 收到的是一句错误的结论**,
> 只能去怀疑 IP 或档案不对。现在资产列表排在最前面判定,并且有回归测试钉着。

想让它确定地跳过选用户(而不是靠识别),把 `userChoice` 留空即可:

- **部署任务**:JSON 里写 `"userChoice": ""`
- **批量连接**:选用户序号那一步**清空直接回车**
- **AI 调用**:`bastion_connect` 的 `userChoice` 传空字符串

> 注意区分两种「空」:**不传** `userChoice` = 按默认走(选用户 1);**传空字符串** = 明确不要选用户。

识别的正则可以用设置覆盖(留空用内置默认,填了**整组替换**):

| 设置 | 识别什么 | 内置覆盖的写法 |
|---|---|---|
| `bastion.menuHints.hostPrompt` | 主菜单(输目标机)**强特征**:菜单正文 | 实测的 `进行搜索`、`输入…IP/主机名/资产`、`选择…资产`、英文 `input/select…host/ip/asset` |
| `bastion.menuHints.hostPromptLoose` | 主菜单的**弱特征**:只有提示符 | `Opt>`(实测环境会重复打印成 `Opt> Opt>`)、`[Host]>`(新加坡那台) |
| `bastion.menuHints.userPrompt` | 二级菜单(选用户) | 实测的 `输入资产[...]的账号ID`、`ID>`、表头 `名称…用户名`、`请选择…用户/账号` |
| `bastion.menuHints.shellPrompt` | 已落到目标机 | `user@host:~$`、`[root@host ~]#`、`bash-4.2$`、单独一行的 `$`/`#` |

> **强特征 vs 弱特征为什么分开**:裸提示符 `Opt>` 只用于「第一次等主菜单」;
> 判断「IP 被拒绝、又回到输 IP」**只能用强特征** —— 因为输完 IP 后那行回显是
> `Opt> Opt> 10.0.0.10`,用提示符判断会把它误当成「又回到输 IP」而白白报错。
> 所以弱特征单独放在 `hostPromptLoose` 里,你可以随时关掉它。

> 如果某家堡垒机的菜单认不出来,看「输出 → BastionShell」通道 —— 现在会**把当时屏幕上的原文整段打出来**,
> 并直接告诉你该改哪个设置:
>
> ```
> 菜单导航「主菜单(输目标机)」未识别到提示,退回固定等待 1200ms
> ——— 这一步没认出来的终端原文(对照它改 bastion.menuHints.hostPrompt)———
> | Welcome to Jumpserver
> | 请输入资产名称或 IP 进行搜索:
> ——— 原文结束 —— (把上面「你在这一步看到的那句提示」写成正则填进 bastion.menuHints.hostPrompt 即可)
> ```
>
> 复制那句提示、写成正则填进对应设置即可,**不用等作者改代码**。`bastion.verboseLog` 打开后还会打更多诊断细节。

## 高危命令

`rm -rf /`、`mkfs`、`dd of=/dev/sda`、fork 炸弹这类命令,三个入口都拦:

| 入口 | 处理方式 |
|---|---|
| AI 的 `bastion_exec` | **直接拒绝执行**(不因 Always Allow 放行),并把命中的规则和原文回给 AI |
| 快捷命令 | 弹确认框,列出命中的规则和原文,确认才发 |
| 部署任务(`preCommand` / `script`) | 开跑前弹一次确认,列出是哪一段里的哪一句 |

设计上的两个取舍:

- **人有权执行自己的命令。** 所以人工路径用「确认」而不是「拒绝」—— 只有 AI 那条直接拦。
- **宁可漏报,不可误报。** 误报会让人很快学会无脑点「仍然执行」,那道防线就废了。
  所以规则锚定在「几乎不可能是本意」的写法上:`rm -rf /` 报,`rm -rf /tmp/build` 不报;
  `chown -R x /` 报,`chown -R app:app /srv/app` 不报;`dd of=/dev/sda` 报,
  `dd if=/dev/sda of=/backup.img`(备份方向)不报。这些边界都有测试守着。

> ⚠️ 这不是安全边界,只是**防手滑和防 AI 幻觉**。它挡不住有心人(换个写法就绕过了)。
> 真正的边界是「你点确认前看一眼」。

## 面向不同厂商:可配置的识别规则

堡垒机厂商五花八门,菜单文案、密码提示、禁忌命令都不一样。所以**凡是厂商相关的判断都不写死**:

| 要识别什么 | 配在哪 | 内置默认 |
|---|---|---|
| 菜单提示(主菜单 / **选资产** / 选用户 / shell 提示符) | 设置 `bastion.menuHints.*` | 某厂商堡垒机(实测)、JumpServer、齐治、中英日常见写法 |
| 密码提示(决定问密码还是问 MFA) | 设置 `bastion.passwordPromptPatterns` | `password`、`密码`、`密碼`、`口令`、`パスワード`、`암호` 等 |
| 高危命令 | `~/.bastionshell/dangerRules.jsonc` | 12 条(删根、格式化、写块设备…) |
| 提权方式 / 常用目录 / 口头习惯 | `~/.bastionshell/habits.jsonc` | — |

设置类的是「留空用内置,**填了整套替换**」的语义 —— 万一内置规则在某家机器上误判,你能一键关掉它。

### 高危命令规则文件

内置规则能**关掉**、也能**加自己那套**(公司内部禁忌命令、某个业务库的删库语句):

```jsonc
{
  "useBuiltin": true,
  "disabled": ["kill-all"],              // 关掉误报的内置规则(id 见文件头部注释)
  "extra": [
    { "id": "prod-db", "why": "禁止直接改生产库", "pattern": "\\b(UPDATE|DELETE|DROP)\\b" }
  ]
}
  • why 会显示在确认框里 —— 只说「命中了一条规则」等于没说,得让人知道为什么。
  • pattern 是正则源码,编译时不区分大小写。
  • 写错的规则只跳过它自己,不让整张表失效;文件读坏了退回内置(检查高危命令这件事不能因为配置读失败就失效)。
  • 入口:命令面板搜「打开高危命令规则文件」,或侧边栏「连接档案」标题栏的盾牌图标。

菜单认不出来怎么办

不用等作者改代码。认不出来时日志会把当时屏幕上的原文整段打出来:

菜单导航「主菜单(输目标机)」未识别到提示,退回固定等待 1200ms
——— 这一步没认出来的终端原文(对照它改 bastion.menuHints.hostPrompt)———
| 	1) 输入 部分IP,主机名,备注 进行搜索登录(如果唯一).
| Opt> Opt>
——— 原文结束 ——

把那句提示写成正则填进对应设置即可。内置规则就是这么做出来的 —— 上面 Opt> Opt> 那条来自实测:提示符会重复打印,所以规则不能锚定行尾 (第一版写成 ^\s*opt>\s*$ 就栽在这,永远匹配不上)。

命令 ID(供 AI 插件 / 键盘绑定使用)

命令 ID 作用
bastion.connect 选档案并连接
bastion.addProfile 新建连接档案
bastion.batchConnect 批量连接(选档案 + 主机列表)
bastion.exec AI 桥接:在活动会话执行远程命令并回传输出
bastion.uploadToSession 上传文件到当前会话
bastion.rsyncSyncToSession 用 rsync 增量同步文件/目录到当前会话(右键资源管理器;实验性、默认关闭,见上文)
bastion.showMcpInfo 查看/复制 MCP 端点(URL、token、给 VS Code 与其它 AI 客户端用的配置片段)
bastion.addDeployTask / runDeployTask / stopDeployTask / openLastDeployReport / batchRunDeployTasks 部署任务 新建 / 运行 / 停止 / 打开最近报告 / 批量执行
bastion.addQuickCommand / sendQuickCommand 快捷命令
bastion.addForwardRule / startForward 端口转发
bastion.sendSelectionToAI 把终端选中内容发给 AI(默认 Ctrl+Alt+I)
bastion.sendTailToAI 把终端最近输出发给 AI(默认 Ctrl+Alt+Shift+I)
bastion.openHabitsFile 打开个人习惯配置文件
bastion.pickSession 多会话快速切换(状态栏点击)
bastion.toggleReadOnly 切换只读模式(防手滑)
bastion.toggleBroadcast 广播输入:选几个会话,键盘输入同时发过去(取消全部勾选 = 关闭)
bastion.toggleBroadcastMode 广播模式:原样同步(逐键,vim 用)⇄ 整行发送(回车才发)
bastion.pickOverwriteMode 设置上传覆盖方式(跳过/覆盖/改名)
bastion.openQuickCommandsFile 打开快捷命令配置文件
bastion.stopAllForwards 停止全部端口转发
bastion.showTransferHistory / retryTransfer / clearTransferHistory 传输历史:打开 / 重试失败上传 / 清空

终端命名

每个会话的终端名为 用户名@主机(部署/批量连接的会话为 用户名@目标IP)。AI 或用户可据此识别会话。

把终端内容发给 AI

在堡垒机终端里选中一段报错或日志,按 Ctrl+Alt+I 就送进 AI 聊天;没选中东西时按 Ctrl+Alt+Shift+I 发最近若干行输出。

  • 这两个键只在焦点位于终端时生效,不影响编辑器里的 AI 快捷键。
  • 默认只把内容填进聊天输入框(你可以再补一句「这是什么问题」再回车);想直接发出去就把 bastion.sendToAiMode 改成 send。
  • 没装 / 没登录 AI 聊天时自动降级:内容复制到剪贴板,并弹提示。
  • 顺带一提:VS Code 自带「添加终端选中内容到聊天」(终端右键菜单),本扩展的命令是在它基础上补了一键快捷键和降级兜底。

个人习惯(habits.jsonc)

以前 AI 提权只能按写死的剧本走「先问要不要 sudo -i」,于是每台机器、每次会话都重复问同一件事;而 sudo -i 会换成交互式 shell,反而让「命令执行完」的判定失效。

现在把实际做法记进 ~/.bastionshell/habits.jsonc(顶部有完整中文说明,可手改):

{
  "privilege": "ask",            // 全局默认:none | sudo | sudo-i | ask
  "notes": ["改配置前先备份到 /tmp/bak"],
  "profiles": {
    "生产": {
      "privilege": "sudo",       // 账号免密 → 直接 sudo <命令>,不进 sudo -i
      "notes": ["deploy 的 sudo 免密"]
    }
  }
}
  • AI 连接后先读它,按记录执行;只有 ask 或没记录时才问你一次,问完立刻记下来。
  • bastion_habits 工具可以读写它(read / remember / setPrivilege),写入只改目标字段,手写的中文注释不会被冲掉。
  • 记成 privilege: "sudo" 还有个额外好处:sudo <命令> 也能用哨兵标记判定结束,不再依赖「输出静止 3 秒」的兜底猜测。

🔌 让外部 AI 直接用你的堡垒机会话(MCP)

这是本扩展最被低估的功能:它同时是一个 MCP 服务端 —— 于是 Claude Desktop、Trae、CodeBuddy、 Cursor 或者任何支持 HTTP MCP 的客户端,都能直接操作你已经登录好的堡垒机会话:跑命令、读屏幕、传文件。

不用装 node、不用 npm、不用再跑转发器 —— 有 VS Code 就够了。 VS Code 开着,端点就在跑。

一键步骤:

  1. 命令面板 → BastionShell: 让外部 AI 连上(MCP 端点 / 一键复制配置)
  2. 挑一项复制(端点+token / VS Code 手配片段 / 其它客户端片段)→ 粘进那个 AI 客户端的配置里
  3. 回到 AI 里让它「先调 bastion_listSessions 看看有哪些会话」

它比"给 AI 一个终端"强的地方在于:会话是人和 AI 共用同一条。MFA、选资产、输密码这些必须 由人做的步骤你在终端里做完,AI 直接在同一条会话上接着干 —— 既不用把密码/动态码交给 AI, 也不用重新认证。另外它自带几道闸:高危命令(删根、格式化、写块设备)直接不发出去、 会话停在堡垒机菜单上时拒绝发命令(发出去会被菜单吃掉)、只读工具标了 readOnlyHint (客户端不会为它弹确认框)。

端点信息(URL / token)在 ~/.bastionshell/mcp.json;token 是长期有效的,重载窗口不会变。 只有本机能连(只监听 127.0.0.1)。关掉:设置 bastion.mcpEnabled。 详细说明与排查见下文「MCP」一节。

协议版本:已支持 2026-07-28(modern:server/discover、每请求 _meta、resultType、缓存字段、 Tasks 扩展、多轮往返(MRTR)、subscriptions/listen 长连接推送),同时保留 legacy(initialize)—— 老客户端不受影响。详见 docs/mcp-protocol.md。

🖥 一个 AI 同时管多条会话(会话绑定)

这段是给人看的速查:AI 自己会读工具说明,但你得知道它在什么情况下会拒绝、为什么。

问题:一个 AI 同时操作两台机器时,如果"用哪条会话"靠"当前活动窗口 / 最近用过的那条"来判断, 就会漂移 —— 你点一下别的终端、或者另一个 AI 插进来,命令就可能发到错的机器上。

现在的规则(三条,记不住就记第一条):

  1. 每条会话有不可变的 id(s-7f3a),AI 指认会话时:id 精确 > 完整终端名(含 #3)> 别名 > 唯一片段。
  2. 绑定存在服务端(~/.bastionshell/mcp-bindings.json),不是靠 AI 的记忆 —— 连接即自动绑定(A、B、C…),也可 bastion_use 起个自己好记的别名(db1、jump…)。
  3. 只有一条绑定时才可以不指定会话;两条及以上不指定 → 直接拒绝并列出来,绝不猜。
你想做的事 AI 该怎么调 结果
同一台机器开两条独立 shell 再 bastion_connect 一次 新会话(独立的 shell 进程:不同 pid / tty),自动绑成 A、B
想直接起个好记的名字 bastion_connect({…, as:"db1"}) 连接时就绑成 db1,不用再调一次 bastion_use
只打 A bastion_exec({command, session:"A"}) 落在 A,和 B 完全隔离(B 在跑长命令也不影响 A)
忘了自己在哪条 bastion_listSessions 每条标 你的(A) / 你的(B),还会回一段"当前绑定"
用完了某条 bastion_use({session:"B", unbind:true}) 解绑,但会话不关;旧别名 B 失效(防误操作),真 id s-869b 仍可直达
两条都绑着、又没指定 —— 拒绝:你现在绑定了 2 条会话:… 请用 session 参数指定

界面上在哪看:左侧「AI 访问」树(客户端 / 权限 / 绑定,可右键解绑)、右侧「总览与进度」 的「AI 与会话」块(每条会话标 AI A(claude))、状态栏 AI N 一格(点开就是左侧树)。

这些细节容易忘,这里一次说清:

  • 上限 32 条(别名 A…Z,用完接 AA…AF)。到顶再连会被拒,先解绑不用的,或让会话自然关掉(绑定会自动清理)。
  • 绑定 12 小时没更新、或会话被关掉,会自动失效(不会拦住你)。
  • 解绑 ≠ 关会话:解绑只是"我不再用它了",会话还在,真 id 依然可达。
  • 会话 id 重连就变(那确实是新会话);#N 只是给人看的短号。
  • 写操作可以带 expectCursor(读会话时返回的 cursor):会话在你读完之后被用户或另一个 AI 动过,就拒绝执行并让你先 bastion_tail —— 防止"盲执行到别人正在操作的位置"。
  • 重试同一件事时带 idempotencyKey:回包丢了但命令其实跑过时,不会被执行第二遍(rm / service restart / kill -9 这种尤其重要)。
  • 权限档(只有"想让另一个 AI 只许看"时才需要):observer 只读、operator 可写但不能连新机器、full 全权。 没签发独立令牌的客户端用的是主令牌 = 全权 —— 左侧树里会明确这么标,不会让你误以为已经限制过它。
  • 桌面版是单令牌(全权),没有分档;但绑定文件和扩展共用,两边的绑定互相可见。

⏳ 长命令:别用 sleep 硬等,用 wait

长命令(构建、部署、安装)以前最容易出事:命令还在跑,AI 却收到「没等到结束标记」, 然后要么重发命令(危险),要么 sleep 空转(浪费 30 分钟很常见)。

现在 bastion_exec 会明确告诉你三种状态(对齐 MCP 官方 Tasks 扩展的状态词):

status 含义 AI 该做什么
completed 跑完了(带 exitCode) 直接看输出
working 还在跑(带 jobId) 不要重发命令 → bastion_wait({jobId}) / bastion_job({jobId})
input_required 卡在密码 / 动态码 / [y/n] / 菜单上 把提示转告用户,让他在终端里输入

配套的三个工具:

  • bastion_wait({jobId, pattern?, waitMs?}) —— 等结束,或等某个正则出现(pattern:"阶段 5/9 完成")。 等待发生在服务端(一轮 ≤55s),不消耗 AI 的轮次。这是 sleep 的替代品。
  • bastion_job({jobId, sinceOffset, maxBytes}) —— 按字节偏移增量读完整日志(滚屏也不丢),大输出分页读。
  • bastion_cancel({jobId}) —— 发 Ctrl-C 收场;误起的 sleep 300 / tail -f 不用再等人来按。

另外两个参数很关键:

  • quietMs(默认 15s):输出静止多久算"没输出了"。旧实现是 3 秒 —— docker build 阶段之间静默几十秒, 第 3 秒就被误判成"结束",这就是「没等到结束标记」的最大来源。
  • waitMs(默认 15s,服务端夹到 55s):MCP 客户端单次调用有自己的超时(常见 60s), 所以不要指望一次调用阻塞 4 分钟;要等更久就分段调 bastion_wait。

一句话:上一次返回 working 时,改用 wait,禁止重发命令。 契约全文见 docs/exec-protocol.md。

🧪 自检:30 秒确认协议层没坏

命令面板 → BastionShell: 自检执行协议

在选定会话上跑 4 条内置命令,输出一张断言表:

用例 期望
true completed + exitCode 0(退出码 0 不能当成"没拿到退出码")
false failed + exitCode 1
echo selftest-a; echo selftest-b completed,正文两行都在(哨兵截断会露出来)
sleep 8; echo selftest-done 先 working + jobId → 等它自己变 completed

失败项会写原因,例如"期望 completed 且 exitCode 0"、"没自动完成 = 作业自持完成坏了"。

为什么有这个东西:0.3.10 期间踩过的几个坑(退出码 0 被当 falsy、哨兵行前面带 ANSI 就判不出、 静默被误判成完成、命令跑完状态不翻转)只有真机才暴露,前后花了三个 AI 会话、四五轮往返才逐个发现。 现在换环境、换堡垒机、换 AI 客户端,先跑这一条就能自证协议层正常。

安全边界:只跑这 4 条内置命令,不接受自定义输入;会话停在堡垒机菜单上时会拒绝执行。

📋 协议支持:2026-07-28(modern)+ legacy

同一个端点同时支持两个时代,行为由客户端的声明决定,不猜:

客户端 拿到什么
legacy(initialize、无 _meta) 普通结果;wait/job/cancel 照常可用
modern(带 _meta 版本 / 版本头) 结果带 resultType:"complete";列表结果带 ttlMs + cacheScope
声明 clientCapabilities.elicitation input_required 时可多轮往返(requestState + inputResponses)
声明 extensions['io.modelcontextprotocol/tasks'] 长任务拿 resultType:"task",配 tasks/get|cancel|update

配套:server/discover、subscriptions/listen(SSE 推送作业状态)、exec 族四个工具的 outputSchema、 不支持的版本 -32022、头与 _meta 不一致 -32020(只有 modern 请求才用 400/404,legacy 保持 200)。

三条硬规则:没声明的能力一律不给(给了客户端会抛 no handler for elicitation/create, 作业卡死占会话);凭据类提示永远不代填(密码/动态码/口令必须人在终端输入); 降级不是失败(status/exitCode/hint 始终准确)。完整矩阵见 docs/mcp-protocol.md §9。

🎛 提示识别规则可配置(bastion.inputPromptRules)

命令卡在 [y/n]、菜单、密码提示上时,exec 会返回 status:"input_required" 并给出 prompt/hint。 内置认这些:sudo 密码、Password:/密码:、动态码/验证码/OTP、(y/n)/(yes/no)/[y/n](尾部允许冒号)、 Enter passphrase、菜单/选择、Press any key。

认不出的自定义提示(比如 [1-9] 请选择> 之外的写法),自己加一行:

// settings.json
"bastion.inputPromptRules": [
  { "pattern": "\\[1-9\\]\\s*请执行", "hint": "在等菜单选择 —— 让用户在终端里选" }
]

规则先于内置匹配、坏正则会自动跳过、改完即时生效(不用重载)。命中的提示会出现在 bastion_health 的「提示识别」一行里,AI 能知道能认多少种。

AI 桥接(让 AI 在你已经认证好的会话上执行命令)

堡垒机的 MFA 无法自动输入,是 AI 与服务器交互的最大障碍。本扩展用「一次认证 + 连接复用」解决它: 密码和动态码始终由你在终端里手动输入,AI 拿到的只是「一条已经登录好的会话」的句柄。

同一批工具(下表)有三条给 AI 用的路,按助手支持什么来选:

AI 助手 走哪条路 你要做什么
Copilot(agent 模式) ① 语言模型工具(contributes.languageModelTools) 什么都不用做
任何支持 MCP 的助手(Copilot / Cursor / Windsurf / Cline / Roo / Continue / CodeBuddy / 通义灵码 / Trae / Claude Code…) ② MCP 服务(推荐,跨助手) VS Code 里零配置;外部客户端运行一次「BastionShell: 查看 MCP 端点」复制配置
只能读写文件、既不支持 LM 工具也不支持 MCP 的 ③ 文件桥(bastion-command.txt → bastion-last-output.txt) 让 AI 把命令写进文件
工具 作用 只读
bastion_listSessions 列出当前所有会话的终端名 + 每条会话现在能不能用(在 shell 里 / 还停在菜单上) ✅
bastion_health 诊断面板:端点/版本、协议代际、客户端能力(tasks / elicitation ✓✗)、在跑作业、绑定清扫、本次请求头、提示识别规则数。AI 遇到「工具调用出错」时先调它 ✅
bastion_tail 读某条会话屏幕上最后几行(排障;也是「人操作完 → AI 接着干」的确认手段) ✅
bastion_listProfiles 列出连接档案及其提权习惯 ✅
bastion_exec 执行命令,返回结构化状态(completed/working/input_required + exitCode/jobId)。参数:command、session/terminal、waitMs、quietMs、mode、background、logToFile、force、expectCursor、idempotencyKey
bastion_connect 通过堡垒机菜单连接到目标机器(参数:profile + host + userChoice + assetId)
bastion_push 传本机文件到远端(rz;参数:localPath,可选 remoteDir)
bastion_pull 把远端文件拉回本机(sz;参数:remotePath,可选 localDir,默认落工作区 .bastion-downloads/)
bastion_habits 读写个人习惯(action:read / remember / setPrivilege)
bastion_use 把"我正在操作哪条会话"记在服务端(session / as / unbind)—— 上下文被压缩也不丢
bastion_wait 等作业结束、或等某个正则出现(jobId / pattern / waitMs)—— sleep 的替代品 ✅
bastion_job 按字节偏移增量读完整日志(jobId / sinceOffset / maxBytes,大输出分页) ✅
bastion_cancel 发 Ctrl-C 收场(误起的 sleep 300 / tail -f 不用等人按)

三个"能干活"的细节:

  • 退出码:bastion_exec 的返回末尾会带 [退出码 0:命令成功] / [退出码 1:命令以非 0 退出,通常表示失败]。 以前 AI 只能靠读输出猜成败(systemctl restart 失败、脚本里 false 这类根本看不出来)。 交互式命令拿不到结束标记时会明确写「退出码未知」或「没等到命令结束标记 —— 这条命令很可能没执行完」,不让它把不完整的输出当正常结果。
  • 传文件走的是同一套 rz/sz:和你在终端里手动 rz/sz 完全一样的实现(进度、传输历史、覆盖方式设置都共用)。 只支持单个文件 —— 目录请先打包;bastion_push 的 remoteDir 会先 cd 过去再传(rz 只能传到会话当前目录)。
  • bastion_pull 默认落到工作区的 .bastion-downloads/:这样 AI 能用它自己的文件工具直接读回来。 (建议把这个目录加进项目的 .gitignore。)

生产堡垒机完整流程(AI 自动走,不用口头约定):

  1. 先手动连一次堡垒机(输密码 + MFA),连接保持复用。
  2. AI 调 bastion_connect({profile, host, userChoice, assetId?}) → 自动经过菜单(输 IP → 匹配到多条时选资产 → 选用户)落到目标机,返回里带上该机器的提权习惯(以及多条资产时这次选的是哪条)。
  3. AI 调 bastion_exec({command}) 执行命令,命令与输出实时显示在终端(人全程看得见,随时能接手敲键盘)。提权方式按习惯走,不再每次问 sudo -i。
  4. 多会话时用 bastion_listSessions 看名字,bastion_exec 的 terminal 参数指定目标。

MCP 服务(跨助手那条路)

装完本扩展就自动注册好一个 MCP 服务(contributes.mcpServerDefinitionProviders),Copilot 那侧零配置。 想在别的 AI 客户端里用,跑一次命令「BastionShell: 查看 MCP 端点」→ 复制对应格式的配置片段即可 (VS Code 用 servers,CodeBuddy / Trae / Cline 等用 mcpServers,两者都给你)。

  • 只监听 127.0.0.1,并且需要 token(存在 ~/.bastionshell/mcp.json,权限 0600;删掉文件即换新 token)。
  • 自动注册需要 VS Code 1.101+(lm.registerMcpServerDefinitionProvider 从那时起稳定);更老的版本不会报错,只是这一条不生效,命令「查看 MCP 端点」仍然能拿到 URL/token 手动配置。
  • 端口和 token 固定(默认端口 39311),所以外部客户端里粘一次的配置一直有效;端口被占时自动退到随机端口并在日志里说明。
  • 它不自己持有 SSH:服务只是个「遥控器」,真正的会话在 VS Code 的扩展宿主里 —— 所以 VS Code 得开着,MFA 也仍然由你手动完成。
  • 安全策略和人类/AI 那两条路完全一致:命中高危规则的命令一律拒绝;只读工具带 readOnlyHint(客户端不弹确认),exec / connect 由客户端弹确认。
  • 出问题看两处:命令面板那条「查看 MCP 端点」(复制 URL/token/配置),以及「输出 → BastionShell」日志(端点地址、请求错误都记在那里)。

在 VS Code 里,工具名长这样:mcp_<服务器名>_<工具名>,也就是 mcp_bastionshell_bastion_listSessions / mcp_bastionshell_bastion_exec / mcp_bastionshell_bastion_connect … (服务器名故意用 bastionshell 而不是带连字符的写法:模型复述这个长名字时少一个出错点。)

AI 说「工具调用出错」时,按这三层查(一层的证伪成本都不到 10 秒):

现象 多半是 怎么确认
报「工具不存在 / 未知工具」 模型把 mcp_… 那个长名字记错了(它自己在 planning 里就会纠结用哪个) 直接告诉它用 mcp_bastionshell_bastion_listSessions,或在聊天里输入 # 从工具列表里选
报连接失败 / 超时 端点当时不可用:刚装完扩展(宿主重载的那一瞬间)、或默认端口被别的窗口占了退到随机端口而客户端还拿着旧 URL 看「输出 → BastionShell」里那两行端点日志(有没有「已退到随机端口」);MCP: List Servers 里重启一次
返回里带着 工具执行失败:… 工具真的跑了但内部抛错 这就是真 bug,把这段文字发过来

我们已经在工具说明里写了一条自救路径,AI 不需要自己摸索: ① 原样重试一次 → ② 调 bastion_health(只读,能返回就说明端点、鉴权、协议都是好的)→ ③ 连它都调不动,就让用户重启 MCP 服务器(MCP: List Servers → BastionShell → Restart)。 说明里还明确写了「不要因为一次工具调用失败就让用户手工去敲命令」—— 那会把一个可修的小问题变成「用户自己干活」。

另外:任何一次 bastion_listSessions 的成功返回里都会带一行 (本次调用来自 MCP 端点 http://127.0.0.1:<port>/mcp) —— 有这行就说明端点是通的(多窗口时还能看出调的是哪个窗口), 没有这行而 AI 却说它拿到了结果,那一定是别的东西在冒充。

为什么不做成「自己连 SSH 的独立 MCP 服务器」(社区里那些 ssh-mcp):那样就必须把密码/私钥交给 AI 那一侧、 也过不了交互式 MFA 和堡垒机菜单。这里刻意反过来 —— 复用你已经登录好的会话。

人和 AI 共用同一条会话(双向接手)

这一条值得单独说,因为它只有这种架构做得到:

你手动过 MFA / 选资产 / 输密码  →  AI 接着在同一条会话上执行命令
AI 遇到需要人来的地方           →  你直接在终端里操作  →  AI 继续
  • 你随时可以抢过键盘:AI 正在干(或者卡住)的时候,你可以直接在终端里敲 —— 补一条它没想周全的命令、 过掉一个菜单、输一次密码。AI 不需要重新连接,下一次 bastion_exec 就在同一条会话上继续。
  • 会话状态会告诉 AI 实情:bastion_listSessions 会标出每条会话是「✅ 在 shell 里」还是 「⚠️ 还停在堡垒机菜单上」;会话停在菜单上时,bastion_exec 不会把命令发出去 (发出去只会被菜单当成菜单输入吃掉),而是告诉 AI 去请你先把这一步走完。
  • 不需要把凭据交给 AI:密码在系统加密存储里,动态码你手输,AI 侧只有「一条已经登录好的会话」这个句柄。 那些自己连 SSH 的 MCP 工具做不到这一点 —— 它们必须拿到密码/私钥,而且验证码得在连接前就备好 (SSH_MCP_2FA_CODE 那类做法)。Teleport 官方的 MCP 也在 RFD 里写明 不支持 per-session MFA。

这条协作方式是被用户发现的(2026-09-13:一个 IP 匹配到多条资产时,用户自己在终端里选完, AI 直接接管继续干活)——当时程序还给了一句错误结论(见上面那张资产表的说明),所以这一版把它 从「碰巧能用」变成了明确支持:状态标注、菜单上的执行拦截、以及工具说明里写清这套协作方式。

命令也会写到 <工作区>/.vscode/bastion-command.txt / bastion-last-output.txt(文件桥兜底,供不支持 LM 工具/MCP 的 AI 用)。完成判定优先用哨兵标记(命令结束时打印一行唯一标记),标记不适用于交互式命令时退回「输出静止 3 秒」。

会话终端名默认是 用户@主机(或 用户@目标IP)。VS Code 不允许扩展改终端名,但你可以手动右键终端标签「重命名」,重命名后 bastion_exec 的 terminal 参数能按名字匹配。

配置

  • bastion.terminalLocation:新会话位置,editor(编辑器区,默认)或 panel(底部面板)。
  • bastion.sendToAiMode:发给 AI 的行为,insert(填进聊天输入框,默认)或 send(直接发出)。
  • bastion.tailLines:「发送最近输出」带多少行,默认 80。
  • bastion.sessionNumber:终端名前加会话号(#1、#2…),默认 true。
  • bastion.uploadOverwrite:上传遇到远端同名文件怎么办,skip(默认)/ overwrite / rename。
  • bastion.deployTerminalPolicy:部署结束后会话终端的处理,默认 closeSuccess。
  • bastion.mcpEnabled:把会话工具通过 MCP 暴露给 AI,默认 true(装完即自动注册给 VS Code;关掉则端点不启动)。
  • bastion.mcpPort:MCP 端点监听的本地端口,默认 39311(只在 127.0.0.1 上监听;被占用会自动退到随机端口)。
  • bastion.statusBarItems:底栏显示哪几个常驻格子,默认全部(session / readonly / overwrite / keepterm / broadcast / forward)。删掉某一项 = 不显示那一格;进度类格子不受此设置影响。
  • bastion.menuHints.hostPrompt / hostPromptLoose / userPrompt / shellPrompt:堡垒机菜单提示识别正则,留空用内置默认。

配置文件都在 ~/.bastionshell/,每份顶部都有中文说明,直接改文件即生效:

文件 内容
profiles.jsonc 连接档案(密码不在这里,存在系统加密存储里,连接时输入)
habits.jsonc 个人习惯(提权方式 / 常用目录 / 口头习惯)
forwardRules.jsonc 端口转发规则
quickCommands.jsonc 快捷命令
transferHistory.jsonc 传输历史(自动维护,删掉等于清空)

部署任务 JSON 模板

{
  "name": "部署 myapp 配置",
  "profile": "生产",
  "hosts": ["10.0.0.1", "10.0.0.2"],
  "uploads": ["D:/work/myapp.yaml"],
  "preCommand": ["sudo -i", "cd /etc"],
  "script": ["systemctl restart nginx", "systemctl status nginx"],
  "userChoice": "1"
}

命令字段推荐用数组写法,一行一个元素 —— 和快捷命令、docker compose 一个手感,不用写 \n。 字符串写法("sudo -i\ncd /etc")也支持,两种可以混用,甚至数组元素自带换行也会被拆开。

命令是逐行执行的:每行的输出会和这行对上、写进部署报告。 所以 sudo -i 这类交互式命令单独占一行最合适 —— 它下面那行能精确捕获输出。

字段说明(编辑任务文件时悬停任一字段也会显示同样的说明;写错字段名会直接标红):

字段 说明
name 任务名,显示在侧边栏和报告里
profile 连接档案名,必须和 profiles.jsonc 里的 name 一致
hosts 目标机列表。直连档案不用填(目标机就是档案自己那台);堡垒机档案必须填
uploads 要上传的本地文件路径(建议绝对路径),通过 rz 传到目标机当前目录
preCommand 上传前执行的命令(数组,一行一个元素)
script 上传完成后执行的命令(同 preCommand)
userChoice 堡垒机选用户序号,默认 1;留空 "" = 这台机器不需要选用户
assetId 可选。一个 IP 在堡垒机里匹配到多条资产时要选的资产 ID(就是资产表里 ID 列的数字)。不写:只有一条候选自动选,多条会弹窗问你

任务文件存在扩展专用目录(.jsonc,可写注释),不混进项目文件;「导出」按钮即复制到目标文件夹。

任务结果去哪了

跑完会生成 Markdown 报告(存在扩展目录 reports/),里面有每条命令的实际输出。四个入口都能拿回来:

  • 跑完的提示里点「打开报告」(有失败时会自动打开)
  • 侧边栏「部署任务」标题栏的 📄 图标 → 打开最近一次报告
  • 命令面板搜「打开最近一次部署报告」
  • 右侧总览面板的「最近一次部署」一节,也有「打开报告」按钮

以前只有那条几秒就消失的提示,没点到就再也找不回来(报告埋在扩展目录里,没有任何入口)。 现在报告路径也会写进日志,并留了上面几个入口。

开发与测试

rsync 桥的端到端测试台(真 SSH + 真 pty)

rsync 那条链路有三个 bug 只有真机才暴露(过滤器接错方向、stty raw 生效前的竞态、文件带末尾斜杠), 单元测试一个都拦不住 —— 而每轮都让用户在自己的机器上试一遍,代价太高。所以有一套本地测试台:

# 搭一次(WSL 里起一个只听 127.0.0.1:2222 的 sshd + rsync;用 wsl -u root,不需要管理员)
wsl -u root -d Ubuntu -- bash /mnt/c/.../tools/rsync-e2e/setup-wsl.sh

# 之后每次改完,一条命令
powershell -ExecutionPolicy Bypass -File tools/rsync-e2e/run-e2e.ps1

它跑的是扩展真正在跑的 runBridge(TCP 环回 + shim + 注入 + 过滤 + 收尾), 只把会话通道换成 ssh2 的 shell 通道 —— 客户端是 Windows 上的 rsync.exe,远端是 WSL 里的真 sshd。 已验证它有牙齿:把「文件带末尾斜杠」那条 bug 退回去,它会红,且报出的错和真机上看到的一模一样。

验证矩阵(src/test/rsyncE2EMatrix.test.ts,16 项):单个文件 / 整个目录 / 1MB 随机二进制 sha256 / 8MB(重负载 64MB)大文件 / 多级目录 + 空目录 / 中文名·空格·引号·美元符文件名 / 空文件 / 多选 / 远端多级目录自动创建 / 目标不可写的报错 / 增量(第二次 0 传输)/ 同步后会话仍可用 / 会话被占用时明确拒绝 / 中途取消后会话恢复 / 同步中能报进度 / 目标机已有前半段时只补差 / 4 条会话并发。 两个协议版本都跑过:Ubuntu rsync 3.2.7(协议 31)与 AlmaLinux rsync 3.4.4(协议 32),各 16/16。

# 两个协议版本各搭一套测试台(密钥按端口区分,互不覆盖)
wsl -u root -d Ubuntu        -- bash /mnt/c/.../tools/rsync-e2e/setup-wsl.sh 2222
wsl -u root -d AlmaLinux-10  -- bash /mnt/c/.../tools/rsync-e2e/setup-wsl.sh 2223

# 跑(-Heavy 打开 64MB 与连续 10 次)
powershell -ExecutionPolicy Bypass -File tools/rsync-e2e/run-e2e.ps1 -Port 2222

另外 src/test/rsyncBridgeFuzz.test.ts 用 2700 组随机输入钉住握手过滤器的三个不变量 (精确切在握手处、协议字节逐字节不变、分块与整块一致、伪握手不能被骗)。

E2E 用例在没有测试台环境变量时自动跳过(npm test 与 CI 不受影响,只显示 19 skipped:3 项单点 + 16 项矩阵)。

代码结构

npm install
npm run compile     # 只编译
npm test            # 编译 + 跑单元测试(239 项,不需要启动 VS Code)
npm run test:only   # 不编译,直接跑已编译的测试
npm run package     # 编译 + 打包 vsix

⚠️ Node 必须 ≥ 22。npm test 用的是 node --test "out/test/*.test.js"(通配符形式), Node 20 不支持把通配符交给 --test,会直接报 Could not find '...\out\test\*.test.js'; 换成目录写法(node --test out/test/)在 Node 22+ 上又会失效 —— 两种都实测过。

推送/PR 会自动跑 CI(GitHub Actions):npm ci → npm test → 打包 vsix 上传为产物。

单元测试覆盖纯逻辑层:状态栏格式化与槽位、个人习惯读写、快捷命令三种写法解析、 传输进度/速率/历史、终端输出整理与哨兵判定、配置文件的 JSONC 读写与容错、 高危命令识别(含误报边界)、堡垒机菜单捕获(假终端模拟分块/分屏/旧画面残留)、 部署任务模板与迁移、部署报告渲染。

实现方式是在 Node 里给 vscode 模块打桩(src/testkit/vscode-stub.ts), 所以不用起 VS Code 就能跑,秒级完成,零额外依赖(用 Node 自带的 node:test)。 菜单导航那部分用假终端(src/test/menuNav.test.ts)喂真实抓下来的某厂商菜单原文, 能测「分几段画屏」「旧画面残留」「已经在 shell 里」这些以前只能靠真机试的情况。

测试会把 HOME/USERPROFILE 指到临时目录,绝不会碰你真实的 ~/.bastionshell。 有测试专门守着这条(配置目录哈希比对)。

不覆盖的部分:真实 SSH/MFA、终端交互、状态栏点击、各家堡垒机的实际菜单文案 —— 这些只能在真机上点,改完请务必手动回归一次。 第一次连接时请看「输出 → BastionShell」,确认这三步都打印了「命中提示文本」: 主菜单(输目标机)、输目标机后、目标机 shell 提示符。 如果打印的是「未识别到提示,退回固定等待」,把紧随其后的那段屏幕原文填进 bastion.menuHints.* 即可; 日志还会替你判断:屏幕上有编号列表/提示符 = 是菜单、只是规则没覆盖到;没有菜单特征 = 很可能压根没这一步(把那台机器的 userChoice 留空)。

各文件职责

文件 职责
extension.ts 只做装配:建视图、注册命令与语言模型工具、挂文件监听
state.ts 共享状态。其它模块只能通过 setXxx() 改,grep setter 就能查到谁改了状态
slots.ts 状态栏各槽位刷什么(会话概览 / 只读 / 覆盖方式 / 端口转发)
status.ts 状态栏槽位底座 + 格式化函数(字节 / 时长 / 速率 / 进度条)
sessions.ts 连接、会话、堡垒机菜单导航、连接档案
menu.ts 堡垒机菜单提示识别 + 「输完 IP 后走哪个分支」的决策(纯函数,可测)
terminal.ts Pseudoterminal 桥接、哨兵完成判定、输出整理、只读模式
connection.ts 一条已认证的 SSH 连接(一次 MFA,多开会话)
zmodem.ts / zmodemIo.ts rz/sz 文件传输协议实现
transfer.ts 传输进度追踪 + 历史 + 树视图
deploy.ts 部署任务文件读写与迁移
deployRun.ts 部署执行、进度、会话回收
deployReport.ts 部署报告渲染(纯函数,无 VS Code 依赖,可直接单测)
danger.ts 高危命令识别(纯函数,可测)
habits.ts 个人习惯(提权方式 / 常用目录 / 口头笔记)
quickCommands.ts / forward.ts 快捷命令、端口转发的数据层与树视图
aiBridge.ts AI 桥接:bastion_exec 等语言模型工具的后端
aiSessionApi.ts 会话能力的唯一实现:语言模型工具和 MCP 工具共用它(拦高危命令、提权习惯等不会分叉)
mcpTools.ts / mcpServer.ts MCP:工具定义与分发 / JSON-RPC + Streamable HTTP 端点(都不依赖 vscode,可单独测)
mcpRegister.ts 把 MCP 端点注册给 VS Code(registerMcpServerDefinitionProvider)+ token/端口持久化
aiChat.ts 把终端选中内容 / 最近输出发给 AI 聊天
*Cmd.ts 各功能的界面命令(quickCmd / forwardCmd / transferCmd / configCmd)

大改动怎么验证「只搬位置、没改逻辑」

拆文件、搬代码时,tsc 只能证明「能编译」,证明不了「行为没变」。 tools/ 里有一套比对工具,重构前后各跑一次即可:

node tools/snapshot-decls.js tools/__before.json   # 重构前
# ……重构……
node tools/snapshot-decls.js tools/__after.json    # 重构后
node tools/compare-decls.js tools/__before.json tools/__after.json

它会抽出所有顶层声明的代码(剔掉注释与空白)逐条比对,输出「哪些声明的代码变了」。 预期是变的那几个必须逐条人工确认(比如搬走模块级 let 之后,写状态的地方 必须从 x = v 改成 setX(v));剩下的应该全是 0 差异,且没有任何声明丢失。

顺带一提,tools/make-icon.js 生成商店图标、tools/preview-icon.js 把 PNG 渲染成 终端里的字符画(这台机器上没有任何图像软件,这是唯一能验证图标画对了的办法)。

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft