新 scope 设计:ports / services / workspace / printers

August 12, 2026 · View on GitHub

状态:已按本文实现(2025-08)。本文保留为设计基线;实现与测试见 src/probe.tssrc/render.tssrc/index.tstests/{ports,services,workspace,printers}.spec.ts。 实现遵循与既有 scope(environment/commands/software/resources/apps/serial/usb/network/gpu) 完全一致的模式:只读、无 shell、无 secrets、canonical JSON + 人类可读渲染、 纯函数解析器 + 可注入 runner、失败也是事实。


0. 共享设计原则(新 scope 全部遵守)

  1. 只读。任何数据源都是读操作;模型输入永远不进 shell 参数。
  2. 无 secrets。不读 token、私钥、.env 内容、云凭据。
  3. 失败也是事实。平台不支持、后端缺失、解析失败 → available: false (reason), 绝不抛工具错误(基础设施失败除外)。
  4. 工作量可控且结果不误导。默认输出行数由 maxPorts/maxServices 封顶,返回 total/truncated;显式设为 0 可关闭条目数截断。内容长度和子进程时间仍有边界。
  5. 可测。解析器是纯函数(fixture 驱动单测),平台后端经 NativeRunner / AppDirectoryReader 注入。
  6. canonical 结果:原始数字/枚举,不是格式化字符串;Code Mode 消费者直接用字段。

平台后端约定(与现有 battery/device/usb/gpu 一致):

平台方式
macOS固定参数的 execFile(lsof / launchctl / lpstat)
Linux优先固定参数命令(ss / systemctl / lpstat),纯 Node 读取作为最终回退
WindowsPowerShell CIM/内置 cmdlet 单脚本 → stdout JSON
其他available: false (… unsupported on <platform>)

1. ports — 监听端口

目标

运维排障第一问:「这台机器上哪些端口在监听、谁在监听」。agent 据此判断 「dev server 是否在跑」「端口是否被占」「该连哪个端口」,不必 lsof/netstat 逐个试错。

数据源与平台策略

只报 TCP LISTEN 套接字(UDP 噪声大,v1 不做;见决策记录 D1)。

平台主后端回退说明
macOSlsof -nP -iTCP -sTCP:LISTENnetstat -an -p tcp(无 pid/进程名)本机已验证:输出含 COMMAND PID USER … NAME,NAME 形如 *:57329 (LISTEN)
Linuxss -lntpHnetstat -lntp → 纯 Node 读 /proc/net/tcp + /proc/net/tcp6(无 pid)ss 对当前用户可见进程报 pid/进程名,他人进程不可见(权限事实)
WindowsPowerShell:Get-NetTCPConnection -State Listen → JSON无需管理员即可列监听端口与 OwningProcess

进程可见性:只报当前用户可见的进程。无特权时其他用户的监听端口不出现—— 这是权限事实,不是 bug,文档写明。

Canonical schema

export interface PortFacts {
  scope: 'ports'
  available: boolean
  /** 为什么不可用(平台不支持/后端缺失/解析失败)。 */
  error?: string
  /** TCP 监听套接字,按端口号升序,同 (地址,端口) 去重。 */
  listening: PortEntry[]
}

export interface PortEntry {
  /** 本地地址原样(`*`、`127.0.0.1`、`[::1]`、`0.0.0.0` …)。 */
  address: string
  port: number
  /** 进程名(lsof/ss/Windows 有;netstat 回退无)。 */
  process?: string
  /** 拥有进程 pid(后端提供时)。 */
  pid?: number
}

IPv4/IPv6 不单独成字段:地址含 : 即为 IPv6,渲染器/消费者自判。

渲染示例

[ports]
listening: 23 sockets
*:5173 (node, pid 8123)
127.0.0.1:5432 (postgres, pid 501)
[::1]:631 (cupsd, pid 318)
*:57329 (rapportd, pid 553)

(23 listening TCP sockets)

行序:端口号升序;无监听 → listening: (none found); 不可用 → available: false (<reason>)。进程/pid 缺失时省略 (process, pid N)

配置

字段默认说明
maxPorts128单次返回的监听套接字上限;0 不按条目数截断。结果用 total/truncated 明示完整性

names 参数对 ports 忽略。

安全边界

  • 命令与参数全部固定(lsof -nP -iTCP -sTCP:LISTEN),模型输入不进入命令行。
  • -nP 不做 DNS 反查、不打印端口服务名(避免慢解析与歧义)。
  • 默认输出行数由 maxPorts 封顶;0 可请求完整可见集合,canonical 结果始终用 total/truncated 说明是否截断。

失败模式

情形结果
lsof/ss 缺失且回退也缺失available: false (no port-listing backend found)
命令超时/被杀(NativeRunner 5s 超时)available: false (<error>)
解析出 0 行但命令成功available: true + listening: [](真没有监听,是事实)
其他平台`available: false (port probing is unsupported on )$

\text{Token} 成本

上限 ≈ 128 行 \times ~45 字符 ≈ 6 \text{KB};典型开发机 < 50 个监听 ≈ 2 \text{KB}。 \text{lsof}/\text{ss}/\text{netstat} 都快(<300\text{ms}),不加 \text{TTL} 缓存($all` 里每调用一次即新鲜)。

测试策略

  • parseLsofListening(output, cap):fixture(含 IPv4/IPv6/多进程同端口/无括号行/乱码行)。
  • parseSsListening(output, cap):fixture(含 users:(("name",pid=1234,fd=18)) 与无 users 段)。
  • parseNetstatListeningparseWindowsPorts(json) 同上。
  • collectPorts 注入 runner:后端缺失回退链、超时、空输出。
  • 平台 e2e:macOS 本机跑真 lsof(断言 *:NNNN 行存在)。

2. services — 服务状态

目标

「哪些系统服务没在跑/失败了」。agent 排障时先看失败服务,再决定下一步 (看日志、重启、查配置),而不是盲跑 ps

数据源与平台策略

只列当前用户可见域的服务(macOS 用户域、Linux systemd 全量列表是只读的、 Windows 服务列表无需管理员)。

平台主后端说明
macOSlaunchctl list用户域 launchd 作业;PID Status Label 三列,PID 为 - 表示未运行,Status 为上次退出码
Linuxsystemctl list-units --type=service --all --no-pager --no-legend只读、无需 root;列 UNIT LOAD ACTIVE SUB DESCRIPTION
WindowsPowerShell:`Get-ServiceSelect Status,Name,DisplayName` → JSON

Linux 无 systemd(systemctl 缺失)→ available: false (no systemd)不做 进程列表回退(进程列表不是服务状态,诚实优于猜测;D2)。

Canonical schema

export interface ServiceFacts {
  scope: 'services'
  available: boolean
  error?: string
  /** 可见域:'launchd:user' | 'systemd:system' | 'windows'。 */
  domain?: string
  services: ServiceFact[]
}

export interface ServiceFact {
  /** launchd label / systemd 单元名 / Windows 服务名。 */
  name: string
  state: 'running' | 'stopped' | 'failed' | 'activating' | 'deactivating' | 'unknown'
  pid?: number
  /** 平台原始状态串(systemd SUB、Windows Status 原文),canonical 映射不丢信息。 */
  detail?: string
  /** launchd 上次退出码(仅 macOS)。 */
  lastExitCode?: number
  /** systemd DESCRIPTION / Windows DisplayName(后端提供时)。 */
  description?: string
}

状态映射:

canonicalmacOS launchctlLinux systemd SUBWindows Status
runningPID 非 -runningRunning
stoppedPID 为 -(Status 0)dead / exitedStopped
failedPID 为 - 且 Status ≠ 0failed
activatingactivating / auto-restartStartPending
deactivatingdeactivatingStopPending
unknown其余其余其余(如 Paused)

渲染示例

[services]
domain: launchd:user
com.apple.AirPlayXPCHelper: running (pid 396)

org.postgresql.postgres: failed (last exit 1)
ssh.service: running (pid 812) — OpenBSD Secure Shell server
(43 services: 38 running, 4 stopped, 1 failed)

行序:先坏后好(failed → activating/deactivating → running → stopped → unknown), 同等级按名字升序——排障时问题服务永远在最前面。汇总行带各状态计数。 无服务 → services: (none found);不可用 → available: false (<reason>)

配置

字段默认说明
maxServices200单次返回的服务上限;0 不按条目数截断,total/truncated 明示完整性

names 参数对 services 忽略。

安全边界

  • 全部固定参数、只读命令;launchctl list 无参数;systemctl list-units 为只读子命令。
  • 进程可见性同 ports:无特权只看到权限内的服务/单元。

失败模式

情形结果
systemctl 缺失 / 非 systemd 发行版available: false (no systemd)
launchctl/systemctl/Get-Service 超时available: false (<error>)
解析 0 行但命令成功available: true + 空列表
其他平台`available: false (service probing is unsupported on )$

\text{Token} 成本

上限 ≈ 200 行 \times ~60 字符 ≈ 12 \text{KB}(有状态描述时);无描述典型 ≈ 4–6 \text{KB}。 后端都快(<500\text{ms}),不加缓存。

测试策略

  • $parseLaunchctlList:fixture(含 PID -`、退出码 0/非 0、空输出、多余空白列)。
  • parseSystemctlUnits:fixture(running/failed/dead/exited/activating、含 description 列)。
  • parseWindowsServices(json):fixture(Running/Stopped/StartPending/Paused)。
  • collectServices 注入 runner:回退链、超时、空输出。
  • macOS e2e:真 launchctl list,断言 label 行存在。

3. workspace — 项目工具链信号

目标

开发者场景:agent 进入一个仓库后,不猜该用 pnpm 还是 npm、Node 版本钉在多少、 构建系统是什么。纯读当前工作目录的文件存在性与少数白名单小文件内容。

关键安全属性:零子进程

本 scope 永不执行任何东西——不跑 git status、不跑 ls、不跑包管理器。 项目目录可能是敌意的(恶意 package.jsonpostinstallls 别名陷阱), 纯 fs 读取是唯一安全边界。这是与其余 scope 最大的不同,写死在设计里。

数据源(全部为 cwd 内的白名单路径)

  1. VCS.git / .hg / .svn 目录存在性 → vcs: 'git' | 'hg' | 'svn'
  2. 包管理器
    • 通过限长文件句柄读 package.json(≤512 KB;最终符号链接拒绝),再 JSON.parse,取 packageManager 字段(如 pnpm@9.1.0)。
    • 锁文件按优先级探测:pnpm-lock.yaml/pnpm-workspace.yaml(pnpm) → yarn.lock(yarn) → package-lock.json/npm-shrinkwrap.json(npm) → bun.lockb/bun.lock(bun)。packageManager 字段与锁文件冲突时两者都如实上报。
    • 无 package.json 但有锁文件 → 名字来自锁文件,无版本。
  3. Node 版本钉.nvmrc.node-version 内容(≤4 KB 读、≤80 字符保留); package.jsonengines.node
  4. 语言/工具版本钉.tool-versions.python-version.ruby-version.go-version.terraform-version 内容(同上限)。
  5. 构建/环境标记文件(存在性,目录条目名匹配,绝不读内容): MakefileCMakeLists.txtmeson.buildbuild.gradlesettings.gradlepom.xmlCargo.tomlgo.modpyproject.tomlrequirements.txtsetup.pyGemfilecomposer.jsonmix.exsstack.yamlflake.nixshell.nixdefault.nixDockerfiledocker-compose.ymlcompose.yaml.github/workflows(目录)、Jenkinsfile.gitlab-ci.ymljustfileTaskfile.yml
  6. env 文件.env.env.example 存在性(文件名入列,内容永不读)。

Canonical schema

export interface WorkspaceFacts {
  scope: 'workspace'
  /** 探测的工作目录(会话 cwd)。 */
  cwd: string
  /** cwd 可读(readdir 成功)。 */
  readable: boolean
  /** readdir 失败原因(cwd 已删/无权限);`readable: false` 时其余字段为空。 */
  error?: string
  vcs?: 'git' | 'hg' | 'svn'
  packageManager?: { name: string; version?: string; lockfile?: string }
  /** 版本钉:{ file, value },value ≤80 字符。 */
  nodePins: { file: string; value: string }[]
  versionPins: { file: string; value: string }[]
  /** 匹配到的标记文件名,升序。 */
  markers: string[]
  /** 发现的 env 文件名(存在性,绝不读内容)。 */
  envFiles: string[]
}

渲染示例

[workspace]
cwd: /Users/developer/Projects/dsh-scout
vcs: git
package manager: pnpm (9.1.0, pnpm-lock.yaml)
node pins: .nvmrc = 20.11.0
version pins: .tool-versions = nodejs 20.11.0
markers: Dockerfile, Makefile, pyproject.toml
env files: .env (presence only, contents not read)

无 VCS → 省略 vcs: 行;无包管理器 → package manager: (none detected); 无标记 → markers: (none);无 env 文件 → 省略。不可读 → available: false 风格: readable: false (ENOENT: …)

配置

字段默认说明
workspaceMarkers上文第 5 条目录标记文件名清单(可增删)
workspaceVersionFiles上文第 3、4 条文件版本钉文件清单

names 参数对 workspace 忽略。

安全边界

  • 零子进程(唯一不变式)。
  • 只探测白名单文件名;readdir 一次,逐名匹配,不递归。
  • 只读三类小文件的内容(版本钉 ≤4 KB、package.json ≤512 KB),读取前验证 basename,生产 reader 拒绝最终符号链接,保留值 ≤80 字符。
  • .env 只报存在性——内容永不读取,注释写死在解析器上。

失败模式

情形结果
cwd 不可读(readdir 失败)readable: false + error,其余字段空
某版本钉文件不可读该文件跳过(缺失即无钉,是事实)
package.json 超限/JSON 非法packageManager 缺席;锁文件推断照常

Token 成本

固定小:~15–25 行 ≈ 1–1.5 KB(标记目录是封闭清单,不可能膨胀)。纯 fs,无超时问题。

测试策略

  • collectWorkspace(cwd, reader) 注入 AppDirectoryReader:fixture 目录(临时目录内 构造各标记/锁文件/版本钉),断言:包管理器推断优先级、packageManager 字段解析 (含 pnpm@9.1.0 与无版本两种)、引擎钉、.env 只在 envFiles、超限内容截断、 cwd 不可读。
  • 回归:在 dsh-scout 自身目录上跑真 collectWorkspace,断言 vcs: gitpackageManager: pnpm(本仓库 pnpm-workspace.yaml 存在)。

4. printers — 打印机

目标

文员场景:机器上有哪些打印机、默认打印机是谁、状态如何。agent 据此判断 「打印任务该投给谁」「打印机是否禁用」。

数据源与平台策略

平台主后端说明
macOSlpstat -p + lpstat -d本机已验证存在;lpstat -p 输出 printer <name> is idle/disabled…lpstat -d 报默认
Linuxlpstat -p + lpstat -dCUPS 同样适用;lpstat 缺失 → available: false (lpstat not found)
WindowsPowerShell:Get-CimInstance Win32_Printer → JSONName/DriverName/PortName/PrinterStatus/Default

lpstat 中文 locale 输出(如本机 lpstat: 未添加目的位置。)视为 0 台打印机—— 命令成功 + 空输出是事实,不是错误。

Canonical schema

export interface PrinterFacts {
  scope: 'printers'
  available: boolean
  error?: string
  /** 默认打印机名(lpstat -d / Windows Default)。 */
  defaultName?: string
  printers: PrinterFact[]
}

export interface PrinterFact {
  name: string
  status: 'idle' | 'busy' | 'disabled' | 'error' | 'offline' | 'unknown'
  /** Windows DriverName。 */
  driver?: string
  /** Windows PortName。 */
  port?: string
}

状态映射:lpstat 文案 is idle → idle、is busy → busy、is disabled → disabled、 其余 → unknown;Windows PrinterStatus:3→idle、4→busy、6→error、7→offline、 8→error(卡纸)、其余→unknown。

渲染示例

[printers]
default: HP_LaserJet
HP_LaserJet: idle
Brother_QL: disabled (driver Brother QL-800, port USB001)
(2 printers found)

无打印机 → printers: (none found);无默认 → 省略 default: 行; 不可用 → available: false (<reason>)。行序按名字升序。

配置

无新配置项。names 参数支持:按打印机名大小写不敏感精确匹配过滤 (与 appsnames 语义一致,走自由文本匹配而非严格探测名校验)。

安全边界

  • lpstat 固定参数(-p / -d),只读;命令名固定。
  • Windows 脚本只 SELECT 白名单字段。
  • 输出行数天然有界(打印机数量),无需 cap。

失败模式

情形结果
lpstat 缺失(非 CUPS 环境)available: false (lpstat not found)
命令超时available: false (<error>)
命令成功但 0 台available: true + (none found)(含中文「未添加目的位置」)
其他平台available: false (printer probing is unsupported on <platform>)

Token 成本

极小:每台一行 ≈ 60 字符;典型 <10 台,<1 KB。lpstat 快,不加缓存。

测试策略

  • parseLpstatPrinters(output):fixture(idle/disabled/busy、英文与中文空输出、 多行续行、lpstat -d 无默认)。
  • parseWindowsPrinters(json):fixture(各 PrinterStatus 码、无默认)。
  • collectPrinters 注入 runner:缺失回退、超时、空输出。
  • macOS e2e:真 lpstat -p,断言 available: true(本机 0 台 → 空列表)。

5. 集成点(实现清单)

文件改动
src/probe.ts新增 4 组接口与收集函数:PortFacts/collectPortsServiceFacts/collectServicesWorkspaceFacts/collectWorkspacePrinterFacts/collectPrinters;全部解析器为纯函数导出
src/render.tsrenderPorts/renderServices/renderWorkspace/renderPrinters + renderResult/SingleScoutResult/AllScoutResult 扩展
src/index.tsSCOPES/SINGLE_SCOPES 加入 4 项;新增 4 个 canonical schema 并纳入 SINGLE_SCOPE_SCHEMAS/ALL_SCHEMAcollectSingle/collectAll 分支;TOOL_DESCRIPTION 补一句;Config 加 maxPorts/maxServices/workspaceMarkers/workspaceVersionFilesnames 描述更新(printers 支持)
src/probe.ts(提示词)index.tstool:scout 段落补充:…before assuming listening ports, service state, project toolchain, or printers…
tests/ports.spec.tsservices.spec.tsworkspace.spec.tsprinters.spec.ts(fixture 驱动);loader.spec.ts 注册表管线补 4 个 scope 的冒烟
README.zh.md / README.md工具表补 4 行;「已知限制与待办」更新(移走已完成项)

all 语义:包含全部新 scope(它是显式 opt-in,代价见下)。

all 的 Token 影响

all 将新增 ~8–20 KB 输出(ports 2–6 KB + services 4–12 KB + workspace ~1.5 KB + printers <1 KB)。设计上接受:all 是穷举请求,agent 应该按需取 scope; 文档在 TOOL_DESCRIPTION 里已强调「request only the scope you need」。


6. 决策记录(ADR)

#决策理由
D1ports 只做 TCP LISTEN,不做 UDPUDP 行数是 TCP 的数倍且多为噪音;v1 聚焦「能否连上」
D2services 在无 systemd 时不回退进程列表进程 ≠ 服务状态;诚实报告 available: false 优于猜测
D3workspace 零子进程项目目录不可信;git status/npm 都可能触发钩子或慢执行
D4空输出语义:ports/services/printers 空 = 真事实(available: true + 空列表);usb/gpu 沿用旧语义(空 = available: falselpstat/ss 空输出是「确实没有」,system_profiler 空树常常是后端坏了;按后端可靠性区分,不统一
D5services 渲染先坏后好(failed 在最前)排障场景问题服务必须一眼可见
D6ports 不加 TTL 缓存lsof/ss <300ms;缓存反而给 agent 过期事实。慢后端(system_profiler 等)才缓存
D7printers 支持 names 过滤,ports/services/workspace 忽略 namesprinters 数量可多可少需要过滤;其余有天然 cap 且无名字概念
D8all 包含新 scope保持「all = 全部」的直观语义;代价在文档中明示

7. 待确认问题(实现时已决议)

  1. launchctl 可达性(已实测定案):在 dsh agent 的 shell 上下文里 launchctl list 退出码 1、零输出、空 stderr(进程不在用户 GUI bootstrap 域,连不上 launchd)。实现:
    • 解析器区分「命令成功但输出空」(= 用户域无作业,available: true + 空列表)与「命令非零退出」(= available: false (launchctl list failed with exit N));
    • macOS e2e 测试把两种结果都当作合法事实断言;
    • launchctl print gui/<uid> 作为 v1 未采用的备选。
  2. netstat 回退(已实现)ports 在 macOS(lsof 缺失)与 Linux(ss 缺失)都实现了 netstat 回退,Linux 另有 /proc/net/tcp 纯 Node 兜底(无 pid,地址/端口内核级可见)。
  3. CI 标记目录(保持精简)workspaceMarkers 只含 .github/workflows,未加入 .circleci/.travis.yml——v1 保持精简单。

另外两处实现期发现(已落入测试与 README「已知限制」):

  • lpstat 非零退出是常态:本机 lpstat -p 无目的地时 exit 1 且输出在 stderr(中文「未添加目的位置」)。collectPrinters 默认使用宽容 runner(defaultTolerantRunner,进程跑过即保留输出),得到 printers: (none found) 而非错误。
  • lsof 进程名带 \xNN 转义:如 Code\x20Helper;解析器经 decodeLsofName 还原为空格。