Browse Source

fix(open-in-app): hide workspace app actions over SSH

lsdsjy 2 tuần trước cách đây
mục cha
commit
4a89178785

+ 2 - 2
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md
-2026-08-25-promote-open-anywhere-plugin.md: ee83c424d1454b26c1ce6cf6954105cdbfbb7419
-2026-08-25-promote-open-anywhere-plugin.zh.md: f1696cec10a683d44dcaa3db454d343821fc13c9
+2026-08-25-promote-open-anywhere-plugin.md: a07c87f3b8daff76ecb3d861e29cd4878c3af4b8
+2026-08-25-promote-open-anywhere-plugin.zh.md: 959407885a99ddfca1e4f584f4bba589ff87b9e8

+ 5 - 1
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md

@@ -12,6 +12,8 @@ The community plugin `@dsh-plugins/open-anywhere` (gitlab.deepseek.com/Ciyou/dsh
 
 The first-party feature is named `open-in-app`: it selects the application that opens a workspace directory on the Harness host, not another machine or destination.
 
+An environment with non-empty `SSH_CONNECTION` or `SSH_TTY` produces an empty application catalog before any probe. The client hides the action even when it remembers a choice, and the existing availability checks reject icon and launch requests. SSH port forwarding changes HTTP reachability, not which machine owns the workspace or applications.
+
 The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `packages/host/open-in-app/` (the probe, catalog, and launch routes) and `@deepseek-ai/dsh-client-ui-open-in-app` at `packages/client/ui-open-in-app/` (the split button), mounted in the Web profile by the `dsh-web-app` bundle rows `open-in-app` and `ui-open-in-app`. The promotion is a rewrite, not a vendoring:
 
 - **A host/client package pair, following the `directory-picker-browse`/`ui-directory-picker-browse` pairing**: the host package's `src/index.ts` registers the three HTTP routes on `ctx.webServer` (`GET /open-in-app/apps`, `GET /open-in-app/icon/<id>`, `POST /open-in-app/open`); the ui package's `src/client/index.ts` registers the split button into `conversation.session.header.utilities` through the standard slot/inject currency, with copy in a typed `open-in-app` locale namespace and styling in CSS Modules over `--dsw-*` tokens (the original's hand-injected style tag and inline dropdown are replaced by the `Menu` primitive), over an empty-apply node half that keeps the plugin on the host roster. Route paths and wire payload types have one home, the host package's browser-safe `./shared` subpath (constants and types only); the client bundle inlines it through an `INLINE_SAFE` entry in the client tsdown preset, the same channel `dsh-session`'s wire slices use. The host root exports only the Loader-required plugin values and types; catalog, resolver, launcher, and icon helpers remain source-internal.
@@ -28,6 +30,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t
 
 ## Alternatives considered
 
+**Offer VS Code's remote CLI during SSH sessions.** Its installed executable does not prove a usable editor connection: the inherited IPC socket belongs to a live VS Code connection and can disappear while Harness keeps running. Browser-side SSH-target configuration and local editor handoff remain outside this host-application feature.
+
 **Vendor the plugin's `lib/` as-is under `packages/`.** Fastest, but the hand-authored JavaScript fails typecheck, coverage, i18n, JSDoc, and invariant gates wholesale; keeping it exempt would create a package class the repository deliberately does not have.
 
 **A Typert Remote instead of raw webServer routes.** The apps/open calls fit the Remote RPC shape, but the icon route serves binary PNGs, which the JSON RPC vocabulary does not carry; splitting icons onto a raw route while apps/open ride Remote gives two transports for one feature. Raw routes also match the original's client, and `webhook-github` establishes the validated-raw-route pattern.
@@ -50,7 +54,7 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t
 
 ## Consequences
 
-- The Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
+- Outside SSH sessions, the Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
 - The community plugin's install path remains valid but redundant; its original routes and browser choice key are separate from `open-in-app`, so installations using the first-party feature should remove the community plugin to avoid duplicate header controls.
 - Resolution and icons run lazily, once per host process, so an application installed while dsh runs appears only after restart — accepted; the uninstall direction self-heals through the `ENOENT` single-entry refresh.
 - The catalog is compile-time fixed; extending it means editing `OPEN_IN_APP_CATALOG` and both locale dictionaries together (README Known Limitations). Platform coverage is uneven — several Git GUIs and terminals are macOS-only entries, Windows icons are limited to the 32px stock .NET extraction, Linux follows hicolor rather than the active theme, and CLI-only entries without a desktop record keep the generic icon.

+ 5 - 1
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md

@@ -12,6 +12,8 @@ Status: implemented
 
 第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。
 
+环境中的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时,任何探测开始前就返回空应用目录。即使客户端记住了应用选择,也会隐藏操作入口;已有的可用性检查会拒绝图标和启动请求。SSH 端口转发只改变 HTTP 可达性,不改变工作区或应用所属的机器。
+
 该功能的第一方归属是一对包:`@deepseek-ai/dsh-host-open-in-app` 位于 `packages/host/open-in-app/`(探测、目录与启动路由),`@deepseek-ai/dsh-client-ui-open-in-app` 位于 `packages/client/ui-open-in-app/`(分体按钮),由 `dsh-web-app` bundle 的 `open-in-app` 与 `ui-open-in-app` 两行挂载进 Web profile。转正是重写,不是 vendoring:
 
 - **一对 host/client 包,沿用 `directory-picker-browse`/`ui-directory-picker-browse` 的配对结构**:host 包的 `src/index.ts` 在 `ctx.webServer` 上注册三条 HTTP 路由(`GET /open-in-app/apps`、`GET /open-in-app/icon/<id>`、`POST /open-in-app/open`);ui 包的 `src/client/index.ts` 经标准 slot/inject 通货把分体按钮注册进 `conversation.session.header.utilities`,文案在类型化的 `open-in-app` locale 命名空间中,样式为 `--dsw-*` token 上的 CSS Modules(原插件手工注入的 style 标签与内联下拉被 `Menu` 原语替代),节点半边是让插件出现在主机名册上的空 apply。路由路径与 wire 载荷类型只有一个家:host 包浏览器安全的 `./shared` 子路径(只有常量与类型);client bundle 经 client tsdown preset 的 `INLINE_SAFE` 条目将其内联,与 `dsh-session` 各 wire 切片同一通道。host 根入口只导出 Loader 所需的插件实体与类型;目录、resolver、launcher 与图标 helper 保持源码内部可见。
@@ -28,6 +30,8 @@ Status: implemented
 
 ## 考虑过的替代方案
 
+**在 SSH 会话中提供 VS Code 的远端 CLI。** 已安装的可执行文件不能证明编辑器连接可用:继承的 IPC socket 属于一个仍在运行的 VS Code 连接,Harness 继续运行时它也可能消失。浏览器侧的 SSH 目标配置与本地编辑器唤起不属于这个主机应用功能。
+
 **将插件的 `lib/` 原样 vendor 进 `packages/`。** 最快,但手写 JavaScript 会整体不过 typecheck、覆盖率、i18n、JSDoc 和 invariant 门禁;为其保留豁免会造出仓库刻意不设的包类别。
 
 **用 Typert Remote 而非裸 webServer 路由。** apps/open 调用符合 Remote RPC 形态,但 icon 路由提供二进制 PNG,JSON RPC 词汇承载不了;把 icon 拆去裸路由而 apps/open 走 Remote 会让一个功能有两种传输。裸路由也匹配原插件的客户端,且 `webhook-github` 已确立带校验裸路由的先例。
@@ -50,7 +54,7 @@ Status: implemented
 
 ## 后果
 
-- 只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
+- 非 SSH 会话中,只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
 - 社区插件的安装路径仍然有效但已冗余;其原始路由与浏览器选择键独立于 `open-in-app`,因此使用第一方功能的安装应移除社区插件,避免出现重复的头部控件。
 - 解析与图标每主机进程惰性执行一次,dsh 运行期间安装的应用要重启后才出现——接受;卸载方向经 `ENOENT` 单条目刷新自愈。
 - 目录在编译期固定;扩展它意味着同时编辑 `OPEN_IN_APP_CATALOG` 与两份 locale 词典(README 已知限制)。平台覆盖不均——若干 Git GUI 与终端仅有 macOS 条目;Windows 图标受限于 .NET 标准接口的 32px 提取,Linux 跟随 hicolor 而非当前主题,没有 desktop 记录的纯 CLI 条目则保留通用图标。

+ 1 - 0
packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx

@@ -66,6 +66,7 @@ describe('OpenInAppAction visibility', () => {
     for (const over of [
       { apps: null, cwd: '/w' },
       { apps: [], cwd: '/w' },
+      { apps: [], choice: 'vscode', cwd: '/w' },
       { apps: ['finder'] },
       { apps: ['finder'], cwd: '' },
       { apps: ['someday-an-app'], cwd: '/w' },

+ 2 - 2
packages/host/open-in-app/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/open-in-app/README.md
-README.md: d13ca42031e41c7e3eb6332e22fc4ea26e875dd9
-README.zh.md: 1fafb5a9b4a7c2a5e32b95b70edfaac8b4d10242
+README.md: 1c69f3baec4c9e05fdce2670fa972ba18e9fec54
+README.zh.md: 5065396f2095b94d8af31ec34d12ab64c5bb9b09

+ 2 - 0
packages/host/open-in-app/README.md

@@ -59,6 +59,8 @@ The catalog is a fixed whitelist covering editors and IDEs (Cursor, VS Code and
 
 ### What to expect
 
+When the host environment contains a non-empty `SSH_CONNECTION` or `SSH_TTY`, the application list is empty and the Web header hides Open In, including any remembered choice. The host skips application probing and refuses icon and launch requests for unavailable applications. This rule also applies when an SSH session carries a display or VS Code IPC connection; it does not identify remote deployments whose launchers remove both SSH markers.
+
 Resolution runs lazily, once per host process, on the first request that needs it; installing an application takes effect on the next restart, while an uninstalled one heals immediately — a launch that finds its executable gone re-resolves that one entry and drops it from the list when nothing proves it anymore. The icon route serves the real application icon on every platform where one is extractable: the bundle's `.icns` as a 128px PNG on macOS, the executable's associated icon as a 32px PNG on Windows, and the desktop entry's hicolor-theme icon (PNG or SVG) on Linux; a missing icon answers 404 and the browser surface renders a generic glyph.
 
 ### The `./shared` subpath

+ 2 - 0
packages/host/open-in-app/README.zh.md

@@ -59,6 +59,8 @@ kind: "package-reference"
 
 ### 预期行为
 
+主机环境中的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时,应用列表为空,Web 头部隐藏 Open In,包括已记住的应用选择。主机跳过应用探测,并拒绝不可用应用的图标和启动请求。SSH 会话即使携带显示服务或 VS Code IPC 连接,也遵循此规则;若启动器移除了两个 SSH 标记,本规则无法识别该远端部署。
+
 解析惰性执行,每主机进程一次,在首个需要它的请求上进行;安装应用要下次重启后生效,卸载方向则立即自愈——启动时发现可执行文件已消失会只重解析该条目一次,无法再证明时把它从列表中移除。图标路由在每个可提取的平台上提供应用真实图标:macOS 上 bundle 的 `.icns` 转 128px PNG,Windows 上可执行文件的关联图标转 32px PNG,Linux 上 desktop 条目在 hicolor 主题中的图标(PNG 或 SVG);提取不到的图标应答 404,浏览器表面渲染通用占位图形。
 
 ### `./shared` 子路径

+ 4 - 0
packages/host/open-in-app/src/resolver.ts

@@ -648,6 +648,7 @@ async function resolveWithRegistry(
  * The returned map is the mutable authority the caller owns — the routes
  * serve its keys and launch from its values, and a stale entry is replaced
  * or removed in place after an `ENOENT` launch.
+ * A non-empty SSH_CONNECTION or SSH_TTY returns an empty map without probing.
  * @param probeTimeoutMs - per-command deadline for resolution host commands.
  * @param internals - platform and runner hooks for deterministic tests.
  * @returns catalog id to verified launch, in catalog order.
@@ -656,6 +657,9 @@ export async function resolveOpenInAppApps(
   probeTimeoutMs: number, internals: OpenInAppInternals = {},
 ): Promise<Map<string, OpenInAppResolvedLaunch>> {
   const resolved = resolveInternals(internals)
+  if ([resolved.env.SSH_CONNECTION, resolved.env.SSH_TTY].some(value => value !== undefined && value !== '')) {
+    return new Map()
+  }
   const registry = new RegistryViewOnce(probeTimeoutMs, resolved)
   const entries = await Promise.all(OPEN_IN_APP_CATALOG.map(async app =>
     [app.id, await resolveWithRegistry(app, probeTimeoutMs, registry, resolved)] as const))

+ 26 - 0
packages/host/open-in-app/tests/host-routes.spec.ts

@@ -45,6 +45,7 @@ function pathTable(entries: Record<string, string> = {}): (name: string) => Prom
 
 /** Boot webserver + open-in-app rows through the real Loader. */
 async function boot(): Promise<string> {
+  internals.catalog = { env: {}, ...internals.catalog }
   root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-loader-'))
   const configPath = join(root, 'cordis.yml')
   await writeFile(configPath, [
@@ -125,6 +126,31 @@ async function cursorBundle(home: string): Promise<void> {
 }
 
 describe('open-in-app host routes (real Loader composition)', () => {
+  it.each([
+    { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' },
+    { SSH_TTY: '/dev/pts/3' },
+  ])('returns an empty catalog and refuses icons and launches over SSH: %j', async (env) => {
+    const run = vi.fn<NativeCommandRunner>()
+    const launch = vi.fn<OpenInAppLauncher>()
+    const resolveExecutable = vi.fn(pathTable())
+    internals.catalog = { platform: 'darwin', env, run, launch, resolveExecutable }
+    const base = await boot()
+
+    const apps = await fetch(`${base}/open-in-app/apps`)
+    expect(apps.status).toBe(200)
+    expect(await apps.json()).toEqual({ apps: [] })
+    expect((await fetch(`${base}/open-in-app/icon/finder`)).status).toBe(404)
+    const open = await fetch(`${base}/open-in-app/open`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ app: 'finder', path: root }),
+    })
+    expect(open.status).toBe(400)
+    expect(run).not.toHaveBeenCalled()
+    expect(resolveExecutable).not.toHaveBeenCalled()
+    expect(launch).not.toHaveBeenCalled()
+  })
+
   it('keeps the function-plugin runtime surface to Loader exports', () => {
     expect(Object.keys(OpenInApp).sort()).toEqual(['Config', 'apply', 'inject', 'name'])
   })

+ 24 - 1
packages/host/open-in-app/tests/resolver.spec.ts

@@ -54,7 +54,7 @@ function byId(id: string): OpenInAppApp {
 
 /** Internals baseline every call completes: a rejecting runner and an empty PATH. */
 function bare(overrides: OpenInAppInternals): OpenInAppInternals {
-  return { run: runner(() => null), resolveExecutable: pathTable(), ...overrides }
+  return { env: {}, run: runner(() => null), resolveExecutable: pathTable(), ...overrides }
 }
 
 /** Hermetic Linux environment: XDG lookups stay inside the temp home. */
@@ -63,6 +63,29 @@ function linuxEnv(home: string): Readonly<Record<string, string>> {
 }
 
 describe('resolveOpenInAppApps', () => {
+  it.each(['darwin', 'win32', 'linux'] as const)('offers no applications over SSH on %s without probing', async (platform) => {
+    for (const env of [
+      { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' },
+      { SSH_TTY: '/dev/pts/3' },
+    ]) {
+      const run = vi.fn<NativeCommandRunner>()
+      const resolveExecutable = vi.fn(pathTable({ code: '/usr/bin/code' }))
+      await expect(resolveOpenInAppApps(TIMEOUT_MS, {
+        platform, env: { ...env, DISPLAY: ':0', VSCODE_IPC_HOOK_CLI: '/tmp/vscode.sock' },
+        run, resolveExecutable,
+      })).resolves.toEqual(new Map())
+      expect(run).not.toHaveBeenCalled()
+      expect(resolveExecutable).not.toHaveBeenCalled()
+    }
+  })
+
+  it.each([{}, { SSH_CONNECTION: '', SSH_TTY: '' }])('keeps local applications available with SSH markers %j', async (env) => {
+    const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({
+      platform: 'darwin', applicationRoots: [], env,
+    }))
+    expect([...map.keys()]).toEqual(['finder', 'terminal'])
+  })
+
   it('fails loud when the PATH resolver is not supplied', async () => {
     await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'linux' }))
       .rejects.toThrow(/resolveExecutable is required/)