diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml index 0fa0a4cd7e..2f5a36785b 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml +++ b/.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: 83888cb548046cd8e023cd2b7c87f123cda0ed0f +2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96 diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md index ee83c424d1..83888cb548 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md +++ b/.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. +The shared `launchedThroughSsh()` predicate in [launch-environment](../../../../packages/util/launch-environment/README.md) reads non-empty `SSH_CONNECTION` or `SSH_TTY` only from the inherited process layer. An SSH launch produces an empty application catalog before any probe. Project and user `.env` values cannot establish an SSH launch; Web browser handoff and the adaptive directory picker use the same predicate. 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/`, `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,8 +54,8 @@ 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. -- Coverage: resolver logic (every locator kind over temp filesystems, registry-dump and desktop-entry fixtures, an injected env/home/PATH table), per-platform icon extraction, the three routes (real Loader + real WebServer composition, including the one-pass cache, the `ENOENT` refresh, and HMR-safety disposal), controller wire behavior, and component presentation are unit-tested to the per-file 100% gate; no snapshot is added because the shipped keyless snapshot fixtures assert session-driven output, which this browser-side control never touches. The web ARIA goldens disable the `open-in-app` and `ui-open-in-app` rows, and the Host-only preset e2e composition disables the host row: the button reflects whatever applications the running machine has installed, so its presence and label are host facts no cross-platform golden can pin. +- Resolver, icon, route, controller, and component tests cover platform discovery, launch outcomes, the availability cache, and HMR disposal. The [SSH Web snapshot](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) renders the shared recorded conversation with both Open In rows enabled and a remembered app choice, capturing only the Session header; composer and statistics output belong to their own snapshots. Inherited SSH markers make the empty catalog deterministic across platforms. Ordinary Web snapshots keep host-dependent application discovery disabled. diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md index f1696cec10..4ef476ce70 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md @@ -12,6 +12,8 @@ Status: implemented 第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。 +[launch-environment](../../../../packages/util/launch-environment/README.zh.md) 中共用的 `launchedThroughSsh()` 只从继承的进程层读取非空 `SSH_CONNECTION` 或 `SSH_TTY`。SSH 启动时会在任何探测开始前返回空应用目录。项目与用户 `.env` 中的值不能作为 SSH 启动的依据;Web 浏览器唤起和自适应目录选择器共用此判断。即使客户端记住了应用选择,也会隐藏操作入口;已有的可用性检查会拒绝图标和启动请求。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/`、`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,8 +54,8 @@ 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 条目则保留通用图标。 -- 覆盖:resolver 逻辑(每种 locator 在临时文件系统上、注册表转储与 desktop 条目 fixture、注入的 env/home/PATH 表)、逐平台图标提取、三条路由(真实 Loader + 真实 WebServer 组合,含单趟缓存、`ENOENT` 刷新与 HMR 安全处置)、controller wire 行为和组件呈现都以逐文件 100% 门禁做了单元测试;不新增 snapshot,因为随仓库发布的免密 snapshot fixture 断言会话驱动的输出,而这个纯浏览器侧控件不触及它。Web ARIA golden 禁用 `open-in-app` 与 `ui-open-in-app` 两行,Host-only 的 preset e2e 组合禁用 host 行:按钮反映运行机器实际安装了哪些应用,其出现与否和标签都是主机事实,跨平台 golden 无法钉住。 +- 解析器、图标、路由、控制器与组件测试覆盖平台探测、启动结果、可用性缓存和 HMR 处置。[SSH Web 快照](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) 在启用两个 Open In 配置项并记住应用选择的条件下渲染共享的录制会话,并仅捕获会话头部;输入框和统计栏由各自的快照负责。继承的 SSH 标记使空应用目录在不同平台上保持确定。普通 Web 快照仍禁用依赖主机的应用探测。 diff --git a/apps/web/package.json b/apps/web/package.json index 47946e5b22..6de8fd4428 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,6 +54,7 @@ "typescript": "^6.0.3", "vite": "^6.0.0", "vitest": "^4.1.8", - "ws": "8.21.0" + "ws": "8.21.0", + "@deepseek-ai/dsh-launch-environment": "workspace:^" } } diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts new file mode 100644 index 0000000000..e8b433c9e9 --- /dev/null +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -0,0 +1,73 @@ +/** SSH launch behavior over a recorded conversation and the shipped Web plugin rows. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/open-in-app-ssh', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED_ID = 'open-in-app-ssh-web-e2e' +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([ + { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }, + ]), + }) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.addInitScript(() => { + localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode')) + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length > 0) throw new AggregateError(failures, 'Open In SSH scenario teardown failed') + }) + + it('hides a remembered app after the real host returns an empty catalog', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-open-in-app-ssh')) + const [response] = await Promise.all([ + page.waitForResponse(response => new URL(response.url()).pathname === '/open-in-app/apps'), + (async () => { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + })(), + ]) + expect(response.status()).toBe(200) + expect(await response.json()).toEqual({ apps: [] }) + expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0) + expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"') + const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 2b66a6035b..f51379705e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -31,6 +31,7 @@ import { pathToFileURL } from 'node:url' import type { Page } from 'playwright' import { expect } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' @@ -284,6 +285,8 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { + /** Enable the real Open In rows with deterministic launch-environment facts. */ + openInAppEnvironment?: LaunchEnvironmentSnapshot /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */ compareReplaySession?: boolean /** @@ -600,13 +603,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise() const stopObservingSessions = ctx.on('session/created', (session) => { observedSessions.set(session.id, session) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index cdfc17fae0..8299e0b6da 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -65,6 +65,7 @@ "tests/web-search-round.e2e.ts", "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/open-in-app-ssh.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d52edac87b..8c4e980c28 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 12e308b54e0a3cded9ef06f2963a3b6fb3c993b8 -config-catalog.zh.md: 42ea196d28ea8e628e31a235ef71760ece18f6ab +config-catalog.md: 90390cab85be57485ad5446e1897f4f11be49ef5 +config-catalog.zh.md: 053ca6d1e357c07524b4b993447a59ed69761a88 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 12e308b54e..90390cab85 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -948,7 +948,7 @@ export interface Config { } ``` -Source: [`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts) +Source: [`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 42ea196d28..053ca6d1e3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -950,7 +950,7 @@ export interface Config { } ``` -来源:[`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts) +来源:[`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 4f0367a44f..63edf46529 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -21,7 +21,7 @@ import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' -import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -81,15 +81,6 @@ const LOOPBACK_HOST = '127.0.0.1' /** The webserver schema's all-interfaces bind literal. */ const ALL_INTERFACES_HOST = '0.0.0.0' -/** Whether this process was launched through SSH, including a forwarded-port session. */ -function launchedThroughSsh(ctx: Context): boolean { - const environment = launchEnvironmentOf(ctx) - return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => { - const value = environment.getFrom(name, ['process'])?.value - return value !== undefined && value !== '' - }) -} - const BROWSER_OPENER_MODULE = import.meta.resolve('open') const BROWSER_OPENER_PROGRAM = ` @@ -235,7 +226,7 @@ export function apply(ctx: Context, config: Config): void { const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts) // The loopback URL belongs to this host. Under SSH, the operator reaches it // through a local forwarding address that this process cannot derive. - const handoffBrowser = config.openBrowser && !launchedThroughSsh(ctx) + const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx)) // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index b34db27a1d..42d6ee0a02 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/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' }, diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index 88f9ed5478..548a1409ae 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/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/directory-picker-auto/README.md -README.md: b4d1c44c84c555f92a42e277571e2f01fc6ae7ee -README.zh.md: 39eeeb0ff8ebec8757be52c9029e6d05688be8f2 +README.md: 49079bba2b0e3597b9a560dd39b394b081b2f904 +README.zh.md: c09dd158db40d697453cd06f48e327d627f4b3fe diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index b4d1c44c84..49079bba2b 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -29,7 +29,7 @@ Compose this plugin instead of a concrete backend when the same composition must ### How the choice is made -`native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `webServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank), and a servable display session — assumed on darwin and win32; on linux, `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH`; never on any other platform. Anything ambiguous resolves to `browse`, which works everywhere. +`native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `webServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (the shared [launch-environment](../../util/launch-environment/README.md) predicate ignores project/user `.env` values and checks only inherited non-empty `SSH_CONNECTION`/`SSH_TTY`), and a servable display session — assumed on darwin and win32; on linux, `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH`; never on any other platform. Anything ambiguous resolves to `browse`, which works everywhere. ### What you get diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index 39eeeb0ff8..c09dd158db 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 选择是如何作出的 -`native` 要求「操作者看得到宿主屏幕、且原生后端能服务它」的全部信号:仅回环的绑定(从注入的 `webServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION`/`SSH_TTY` 未设置或为空);以及可服务的显示会话——darwin 与 win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制;其余任何平台上都不成立。任何含糊情形都判定为处处可用的 `browse`。 +`native` 要求「操作者看得到宿主屏幕、且原生后端能服务它」的全部信号:仅回环的绑定(从注入的 `webServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(共用的 [launch-environment](../../util/launch-environment/README.zh.md) 判断忽略项目与用户 `.env` 中的值,只检查继承的非空 `SSH_CONNECTION`/`SSH_TTY`);以及可服务的显示会话——darwin 与 win32 上视为存在;linux 上要求 `DISPLAY`/`WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制;其余任何平台上都不成立。任何含糊情形都判定为处处可用的 `browse`。 ### 你会得到什么 diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index d7396473f6..620d7470a0 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -45,5 +45,8 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/dsh-launch-environment": "workspace:^" } } diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 8e41edef80..f384828125 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -15,6 +15,7 @@ import type { Context } from '@deepseek-ai/cordis' // Empty type imports carry the `loader` and `webServer` Context merges for the reads below. import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' +import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { canExecute, hasLinuxChooserBinary } from './probe.ts' import type { DirectoryPickerBackendKind } from './resolve.ts' import { resolveDirectoryPickerBackend } from './resolve.ts' @@ -62,6 +63,7 @@ export async function apply(ctx: Context): Promise { const backend = resolveDirectoryPickerBackend({ bindHost: ctx.webServer.host, platform: process.platform, + ssh: launchedThroughSsh(launchEnvironmentOf(ctx)), env: process.env, linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute), }) diff --git a/packages/host/directory-picker-auto/src/resolve.ts b/packages/host/directory-picker-auto/src/resolve.ts index 395e2da55f..b048376784 100644 --- a/packages/host/directory-picker-auto/src/resolve.ts +++ b/packages/host/directory-picker-auto/src/resolve.ts @@ -13,7 +13,7 @@ export type DirectoryPickerBackendKind = 'native' | 'browse' /** Environment keys the resolution reads (a `process.env` subset). */ export type DirectoryPickerEnv = Readonly< - Partial> + Partial> > /** Host facts the backend choice is a pure function of, sampled once at boot. */ @@ -22,7 +22,9 @@ export interface DirectoryPickerHostFacts { bindHost: HttpServerConfig['host'] /** Host process platform. */ platform: NodeJS.Platform - /** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */ + /** SSH launch fact from the inherited process layer, independent of `.env` values. */ + ssh: boolean + /** Environment sample; DISPLAY/WAYLAND_DISPLAY marks a Linux display. */ env: DirectoryPickerEnv /** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */ linuxChooser: boolean @@ -46,7 +48,7 @@ const present = (value: string | undefined): boolean => value !== undefined && v */ export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind { if (facts.bindHost !== '127.0.0.1') return 'browse' - if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse' + if (facts.ssh) return 'browse' if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native' if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse' return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse' diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 29bf3ed30a..49bf319b0c 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -20,6 +20,9 @@ import HttpServer from '@deepseek-ai/dsh-host-webserver' import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse' import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native' +import { + createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot, +} from '@deepseek-ai/dsh-launch-environment' import * as DirectoryPickerAuto from '../src/index.ts' const renameControl = vi.hoisted(() => ({ @@ -90,7 +93,7 @@ afterEach(async () => { /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */ async function loadComposition( bindHost: '127.0.0.1' | '0.0.0.0', - options: { failSurface?: boolean } = {}, + options: { failSurface?: boolean; launchEnvironment?: LaunchEnvironmentSnapshot } = {}, ): Promise<{ ctx: Context; configPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-')) const configPath = join(root, 'cordis.yml') @@ -104,6 +107,7 @@ async function loadComposition( ].join('\n')) context = new Context() + if (options.launchEnvironment !== undefined) context.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.launchEnvironment) context.baseUrl = pathToFileURL(root).href + '/' await context.plugin(Loader) context.loader.builtins.include = Include @@ -163,6 +167,19 @@ function stubAttendedHost(): void { } describe('real Loader composition', () => { + it.each(['project-env', 'user-env'] as const)('keeps the native backend with materialized SSH markers from %s', async (source) => { + stubAttendedHost() + vi.stubEnv('SSH_CONNECTION', 'stale-connection') + vi.stubEnv('SSH_TTY', '/dev/pts/stale') + const launchEnvironment = createLaunchEnvironmentSnapshot([ + { source, values: { SSH_CONNECTION: 'stale-connection', SSH_TTY: '/dev/pts/stale' } }, + ]) + const { ctx } = await loadComposition('127.0.0.1', { launchEnvironment }) + expect(ctx.get('directoryPicker')?.capability().kind).toBe('native') + expect(entryNames(ctx)).toContain(NATIVE_SURFACE) + expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE) + }) + // The 60s budget covers this file's static imports (webserver plus both // backend node halves through tsx), which dominate on cold caches; the // Loader itself resolves nothing here — `loader.internal` is a module map. diff --git a/packages/host/directory-picker-auto/tests/resolve.spec.ts b/packages/host/directory-picker-auto/tests/resolve.spec.ts index bf8792cfa9..ce759beee3 100644 --- a/packages/host/directory-picker-auto/tests/resolve.spec.ts +++ b/packages/host/directory-picker-auto/tests/resolve.spec.ts @@ -10,6 +10,7 @@ import type { DirectoryPickerHostFacts } from '../src/resolve.ts' const attended: DirectoryPickerHostFacts = { bindHost: '127.0.0.1', platform: 'darwin', + ssh: false, env: {}, linuxChooser: false, } @@ -24,9 +25,8 @@ describe('resolveDirectoryPickerBackend', () => { expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse') }) - it('resolves browse under an SSH launch (either env marker)', () => { - expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '10.0.0.2 55 10.0.0.9 22' } })).toBe('browse') - expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse') + it('resolves browse under an SSH launch', () => { + expect(resolveDirectoryPickerBackend({ ...attended, ssh: true })).toBe('browse') }) it('requires a display session and a chooser binary on linux', () => { @@ -43,7 +43,6 @@ describe('resolveDirectoryPickerBackend', () => { }) it('treats blank env exports as unset', () => { - expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native') expect(resolveDirectoryPickerBackend({ ...attended, platform: 'linux', linuxChooser: true, env: { DISPLAY: '', WAYLAND_DISPLAY: '' }, })).toBe('browse') diff --git a/packages/host/directory-picker-auto/tsconfig.json b/packages/host/directory-picker-auto/tsconfig.json index 971363c2ed..b80d793efe 100644 --- a/packages/host/directory-picker-auto/tsconfig.json +++ b/packages/host/directory-picker-auto/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../webserver" + }, + { + "path": "../../util/launch-environment" } ] } diff --git a/packages/host/open-in-app/README.i18n.yaml b/packages/host/open-in-app/README.i18n.yaml index 2cf6a22263..fa5182e13b 100644 --- a/packages/host/open-in-app/README.i18n.yaml +++ b/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: f68a874feba1cad9fe0dda1457b943ac0290fdac -README.zh.md: 17f1c6c181cffa929167dc21a04c769403ac9228 +README.md: 0f0c05f48469a03bc115d84e93a174fbfeba0c42 +README.zh.md: 3ac1c72c66ea0008abe621a7ac8fe82c6caead4c diff --git a/packages/host/open-in-app/README.md b/packages/host/open-in-app/README.md index f68a874feb..0f0c05f484 100644 --- a/packages/host/open-in-app/README.md +++ b/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 inherited process layer of the [launch environment](../../util/launch-environment/README.md) 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. Project and user `.env` values do not establish an SSH launch. 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 diff --git a/packages/host/open-in-app/README.zh.md b/packages/host/open-in-app/README.zh.md index 17f1c6c181..3ac1c72c66 100644 --- a/packages/host/open-in-app/README.zh.md +++ b/packages/host/open-in-app/README.zh.md @@ -59,6 +59,8 @@ kind: "package-reference" ### 预期行为 +[启动环境](../../util/launch-environment/README.zh.md)中继承的进程层的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时,应用列表为空,Web 头部隐藏 Open In,包括已记住的应用选择。项目与用户 `.env` 中的值不作为 SSH 启动的依据。主机跳过应用探测,并拒绝不可用应用的图标和启动请求。SSH 会话即使携带显示服务或 VS Code IPC 连接,也遵循此规则;若启动器移除了两个 SSH 标记,本规则无法识别该远端部署。 + 解析惰性执行,每主机进程一次,在首个需要它的请求上进行;安装应用要下次重启后生效,卸载方向则立即自愈——启动时发现可执行文件已消失会只重解析该条目一次,无法再证明时把它从列表中移除。图标路由在每个可提取的平台上提供应用真实图标:macOS 上 bundle 的 `.icns` 转 128px PNG,Windows 上可执行文件的关联图标转 32px PNG,Linux 上 desktop 条目在 hicolor 主题中的图标(PNG 或 SVG);提取不到的图标应答 404,浏览器表面渲染通用占位图形。 ### `./shared` 子路径 diff --git a/packages/host/open-in-app/package.json b/packages/host/open-in-app/package.json index 06ea232508..2a2e52644c 100644 --- a/packages/host/open-in-app/package.json +++ b/packages/host/open-in-app/package.json @@ -36,6 +36,7 @@ }, "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" diff --git a/packages/host/open-in-app/src/index.ts b/packages/host/open-in-app/src/index.ts index 1c2663425b..0c065e0111 100644 --- a/packages/host/open-in-app/src/index.ts +++ b/packages/host/open-in-app/src/index.ts @@ -26,6 +26,7 @@ import { stat } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-subprocess' +import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import z from '@deepseek-ai/schemastery' import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from './catalog.ts' import { @@ -135,8 +136,10 @@ function parseOpenBody(text: string): { app: string; path: string } | null { /** Register the apps, icon, and open routes behind the connection trust fence. */ export function apply(ctx: Context, config: Config): void { + const ssh = launchedThroughSsh(launchEnvironmentOf(ctx)) /** Test-seam facts completed with the composition's PATH resolver. */ const catalogInternals = (): OpenInAppInternals => ({ + ssh, resolveExecutable: async (name) => { try { return await ctx.subprocess.resolveExecutable(name) diff --git a/packages/host/open-in-app/src/resolver.ts b/packages/host/open-in-app/src/resolver.ts index 37420cb21f..7ab964b4fd 100644 --- a/packages/host/open-in-app/src/resolver.ts +++ b/packages/host/open-in-app/src/resolver.ts @@ -98,6 +98,8 @@ export const launchDetachedApp: OpenInAppLauncher = (command, args, options) => /** Injectable platform facts for deterministic tests. */ export interface OpenInAppInternals { platform?: NodeJS.Platform + /** SSH launch fact from the inherited process layer, independent of `.env` values. */ + ssh?: boolean /** Bundle-directory roots replacing `/Applications` and `~/Applications`. */ applicationRoots?: readonly string[] /** Environment for `${VAR}`/`%VAR%` expansion in candidates and registry values. */ @@ -113,6 +115,7 @@ export interface OpenInAppInternals { /** Platform facts after the one explicit defaulting step at each public entry. */ export interface ResolvedInternals { platform: NodeJS.Platform + ssh: boolean applicationRoots: readonly string[] env: Readonly> home: string @@ -137,6 +140,7 @@ export function resolveInternals(internals: OpenInAppInternals): ResolvedInterna } return { platform: internals.platform ?? osPlatform(), + ssh: internals.ssh ?? false, applicationRoots: internals.applicationRoots ?? ['/Applications', join(home, 'Applications')], env: internals.env ?? process.env, home, @@ -617,12 +621,13 @@ async function locate( * @param app - catalog entry. * @param probeTimeoutMs - per-command deadline for resolution host commands. * @param internals - platform and runner hooks for deterministic tests. - * @returns the verified launch, or null when the entry is not installed here. + * @returns the verified launch, or null during SSH launches or when the entry is not installed here. */ export async function resolveLaunch( app: OpenInAppApp, probeTimeoutMs: number, internals: OpenInAppInternals = {}, ): Promise { const resolved = resolveInternals(internals) + if (resolved.ssh) return null return resolveWithRegistry(app, probeTimeoutMs, new RegistryViewOnce(probeTimeoutMs, resolved), resolved) } @@ -648,6 +653,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. + * An SSH launch 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 +662,9 @@ export async function resolveOpenInAppApps( probeTimeoutMs: number, internals: OpenInAppInternals = {}, ): Promise> { const resolved = resolveInternals(internals) + if (resolved.ssh) { + 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)) diff --git a/packages/host/open-in-app/tests/host-routes.spec.ts b/packages/host/open-in-app/tests/host-routes.spec.ts index 6e1ad883df..ff73819cc0 100644 --- a/packages/host/open-in-app/tests/host-routes.spec.ts +++ b/packages/host/open-in-app/tests/host-routes.spec.ts @@ -20,6 +20,9 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import WebServer from '@deepseek-ai/dsh-host-webserver' import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { + createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentLayerInput, +} from '@deepseek-ai/dsh-launch-environment' import * as OpenInApp from '../src/index.ts' import { internals } from '../src/internals.ts' import type { OpenInAppLauncher } from '../src/resolver.ts' @@ -36,6 +39,7 @@ afterEach(async () => { root = undefined internals.catalog = {} trust.rejection = undefined + vi.unstubAllEnvs() }) /** PATH-resolution fake answering from a fixed name-to-path table. */ @@ -44,7 +48,8 @@ function pathTable(entries: Record = {}): (name: string) => Prom } /** Boot webserver + open-in-app rows through the real Loader. */ -async function boot(): Promise { +async function boot(layers: readonly LaunchEnvironmentLayerInput[] = []): Promise { + internals.catalog = { env: {}, ...internals.catalog } root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -61,6 +66,7 @@ async function boot(): Promise { ].join('\n')) context = new Context() + context.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot(layers)) context.baseUrl = pathToFileURL(root).href + '/' context.provide('connection', { requestRejection: () => trust.rejection } as never) // The plugin resolves PATH names through the composition's subprocess @@ -125,6 +131,43 @@ async function cursorBundle(home: string): Promise { } describe('open-in-app host routes (real Loader composition)', () => { + it.each(['project-env', 'user-env'] as const)('ignores materialized SSH markers from %s', async (source) => { + vi.stubEnv('SSH_CONNECTION', 'stale-connection') + vi.stubEnv('SSH_TTY', '/dev/pts/stale') + internals.catalog = { + platform: 'darwin', applicationRoots: [], env: process.env, + run: () => Promise.reject(new Error('fixture rejects')), resolveExecutable: pathTable(), + } + const base = await boot([{ source, values: { SSH_CONNECTION: 'stale-connection', SSH_TTY: '/dev/pts/stale' } }]) + + expect(await (await fetch(`${base}/open-in-app/apps`)).json()).toEqual({ apps: ['finder', 'terminal'] }) + }) + + 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() + const launch = vi.fn() + const resolveExecutable = vi.fn(pathTable()) + internals.catalog = { platform: 'darwin', env, run, launch, resolveExecutable } + const base = await boot([{ source: 'process', values: env }]) + + 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']) }) diff --git a/packages/host/open-in-app/tests/resolver.spec.ts b/packages/host/open-in-app/tests/resolver.spec.ts index 5baf5352a4..36187e415b 100644 --- a/packages/host/open-in-app/tests/resolver.spec.ts +++ b/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,26 @@ function linuxEnv(home: string): Readonly> { } describe('resolveOpenInAppApps', () => { + it.each(['darwin', 'win32', 'linux'] as const)('offers no applications over SSH on %s without probing', async (platform) => { + const run = vi.fn() + const resolveExecutable = vi.fn(pathTable({ code: '/usr/bin/code' })) + const facts = { + platform, ssh: true, env: { DISPLAY: ':0', VSCODE_IPC_HOOK_CLI: '/tmp/vscode.sock' }, + run, resolveExecutable, + } + await expect(resolveOpenInAppApps(TIMEOUT_MS, facts)).resolves.toEqual(new Map()) + await expect(resolveLaunch(byId('vscode'), TIMEOUT_MS, facts)).resolves.toBeNull() + expect(run).not.toHaveBeenCalled() + expect(resolveExecutable).not.toHaveBeenCalled() + }) + + it('keeps local applications available regardless of flattened SSH markers', async () => { + const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({ + platform: 'darwin', applicationRoots: [], env: { SSH_CONNECTION: 'stale-value' }, + })) + 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/) diff --git a/packages/host/open-in-app/tsconfig.json b/packages/host/open-in-app/tsconfig.json index 95ca72663a..12aa74651b 100644 --- a/packages/host/open-in-app/tsconfig.json +++ b/packages/host/open-in-app/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../util/native-command" + }, + { + "path": "../../util/launch-environment" } ] } diff --git a/packages/util/launch-environment/README.i18n.yaml b/packages/util/launch-environment/README.i18n.yaml index d093cb2c8d..afa3022cfd 100644 --- a/packages/util/launch-environment/README.i18n.yaml +++ b/packages/util/launch-environment/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/util/launch-environment/README.md -README.md: abee8731c5c02ce6439499c892f3ed967a6467a1 -README.zh.md: a08a895912f163b1a3dd35d7be5820aa93769507 +README.md: 43d3e27b1901ac7fea655a08ae183084bbbab1ff +README.zh.md: 7d2fc0506f9ab238bb592cbb2613549b1672ed1d diff --git a/packages/util/launch-environment/README.md b/packages/util/launch-environment/README.md index abee8731c5..43d3e27b19 100644 --- a/packages/util/launch-environment/README.md +++ b/packages/util/launch-environment/README.md @@ -37,6 +37,8 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value `get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the named layers without changing that trust order — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. +`launchedThroughSsh(snapshot)` returns true only for a non-empty `SSH_CONNECTION` or `SSH_TTY` in the inherited process layer. Web browser handoff, the adaptive directory picker, and Open In share this predicate; project and user `.env` values never establish an SSH session. + ### How layers rank | Layer | What it is | diff --git a/packages/util/launch-environment/README.zh.md b/packages/util/launch-environment/README.zh.md index a08a895912..7d2fc0506f 100644 --- a/packages/util/launch-environment/README.zh.md +++ b/packages/util/launch-environment/README.zh.md @@ -37,6 +37,8 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value `get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索指定的层,不改变这一可信顺序——绝不能接受某一层的调用方不把它列进去,因此后续任何重新排序都无法让它回来。 +`launchedThroughSsh(snapshot)` 仅在继承的进程层中存在非空 `SSH_CONNECTION` 或 `SSH_TTY` 时返回 true。Web 浏览器唤起、自适应目录选择器与 Open In 共用此判断;项目与用户 `.env` 中的值不作为 SSH 会话的依据。 + ### 各层的优先级 | 层 | 它是什么 | diff --git a/packages/util/launch-environment/src/index.ts b/packages/util/launch-environment/src/index.ts index b12d936666..ddd6ecd46b 100644 --- a/packages/util/launch-environment/src/index.ts +++ b/packages/util/launch-environment/src/index.ts @@ -116,6 +116,19 @@ export function launchEnvironmentOf(ctx: Context): LaunchEnvironmentSnapshot { ?? createLaunchEnvironmentSnapshot([{ source: 'process', values: process.env as Record }]) } +/** + * Detect SSH from non-empty SSH_CONNECTION or SSH_TTY inherited at launch. + * Project and user `.env` values never establish an SSH session. + * @param environment - the launcher's environment snapshot. + * @returns whether the inherited process layer carries either SSH marker. + */ +export function launchedThroughSsh(environment: LaunchEnvironmentSnapshot): boolean { + return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => { + const value = environment.getFrom(name, ['process'])?.value + return value !== undefined && value !== '' + }) +} + declare module '@deepseek-ai/cordis' { interface Context { /** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */ diff --git a/packages/util/launch-environment/tests/launch-environment.spec.ts b/packages/util/launch-environment/tests/launch-environment.spec.ts index 973b86357d..28092eef73 100644 --- a/packages/util/launch-environment/tests/launch-environment.spec.ts +++ b/packages/util/launch-environment/tests/launch-environment.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { - createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchEnvironmentOf, + createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchedThroughSsh, launchEnvironmentOf, } from '../src/index.ts' const layered = createLaunchEnvironmentSnapshot([ @@ -10,6 +10,24 @@ const layered = createLaunchEnvironmentSnapshot([ { source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } }, ]) +describe('launchedThroughSsh', () => { + it.each(['SSH_CONNECTION', 'SSH_TTY'].flatMap(name => + (['process', 'project-env', 'user-env'] as const).map(source => ({ name, source })), + ))('classifies $name from $source', ({ name, source }) => { + const snapshot = createLaunchEnvironmentSnapshot([{ source, values: { [name]: 'ssh-marker' } }]) + expect(launchedThroughSsh(snapshot)).toBe(source === 'process') + }) + + it.each([{}, { SSH_CONNECTION: '', SSH_TTY: '' }])('keeps absent or empty inherited markers local: %j', (values) => { + const snapshot = createLaunchEnvironmentSnapshot([ + { source: 'process', values }, + { source: 'project-env', values: { SSH_CONNECTION: 'stale-connection' } }, + { source: 'user-env', values: { SSH_TTY: 'stale-tty' } }, + ]) + expect(launchedThroughSsh(snapshot)).toBe(false) + }) +}) + describe('createLaunchEnvironmentSnapshot', () => { it('resolves across every layer, most trusted first, and reports the winning source', () => { expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b205881126..3dc0fc3a72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -612,6 +612,9 @@ importers: '@deepseek-ai/dsh-experimental-webworker-runtime': specifier: workspace:^ version: link:../../packages/experimental/webworker-runtime + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local @@ -6421,6 +6424,10 @@ importers: version: link:../../../vendor/cordis packages/host/directory-picker-auto: + dependencies: + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6506,6 +6513,9 @@ importers: packages/host/open-in-app: dependencies: + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command diff --git a/snapshots/web/open-in-app-ssh/header.expected.md b/snapshots/web/open-in-app-ssh/header.expected.md new file mode 100644 index 0000000000..54d84e693e --- /dev/null +++ b/snapshots/web/open-in-app-ssh/header.expected.md @@ -0,0 +1,11 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - button "Session log": + - text: Session log + - img + - button "Open the sidebar": + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" diff --git a/snapshots/web/open-in-app-ssh/snapshot.yml b/snapshots/web/open-in-app-ssh/snapshot.yml new file mode 100644 index 0000000000..202817fa2c --- /dev/null +++ b/snapshots/web/open-in-app-ssh/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: open-in-app-ssh +profile: web +composition: web-default +recording: authored +header: + class: web-default +session: + source: ../seeded-history/session.v2.jsonl diff --git a/tsconfig.host.json b/tsconfig.host.json index 1c68bd9d0b..02f4a3796e 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -53,6 +53,7 @@ "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/file-upload-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/open-in-app-ssh.e2e.ts", "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/message-feedback-layout.e2e.ts", "apps/web/tests/markdown-images.e2e.ts",