From 0bbd34a7b05f648f6f61d0b3b8bea62e940e1850 Mon Sep 17 00:00:00 2001 From: zhouxun Date: Fri, 14 Aug 2026 11:22:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=94=AF=E6=8C=81=20DeepSeek=20Harness?= =?UTF-8?q?=EF=BC=9A=E6=8E=A5=E5=85=A5=E4=B8=80=E7=AD=89=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/content/docs/guides/integrations.md | 23 +- .../content/docs/ja/reference/cli/agents.md | 7 +- .../content/docs/ko/reference/cli/agents.md | 7 +- .../src/content/docs/reference/cli/agents.md | 9 +- .../content/docs/ru/reference/cli/agents.md | 9 +- .../content/docs/tr/guides/integrations.md | 31 +- .../content/docs/tr/reference/cli/agents.md | 6 +- .../docs/zh-cn/reference/cli/agents.md | 7 +- .../content/docs/zh-tw/guides/integrations.md | 15 +- .../docs/zh-tw/reference/cli/agents.md | 27 +- .../docs/zh-tw/reference/management-api.md | 2 +- gui/src/app-routing.ts | 1 + .../client-config-clients.ts | 3 +- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Integrations.tsx | 2 + .../integrations/FileIntegrationPage.tsx | 2 + gui/src/pages/integrations/integration-api.ts | 1 + .../pages/integrations/overview-clients.ts | 3 +- gui/tests/client-config-panel.test.tsx | 10 +- gui/tests/integrations-api.test.ts | 7 + gui/tests/integrations-overview-rows.test.ts | 10 +- gui/tests/integrations-routing.test.ts | 9 + gui/tests/integrations-surfaces.test.tsx | 24 +- gui/tests/locale-parity.test.ts | 55 +++ scripts/dsh-rc6-compat-e2e.ts | 329 +++++++++++++++++ src/cli/export-command.ts | 26 +- src/cli/help.ts | 2 +- src/cli/registry.ts | 4 +- src/clients/config-export.ts | 168 ++++++++- src/integrations/omp-yaml-source.ts | 336 ++++++++++++------ src/integrations/registry.ts | 14 + src/integrations/state.ts | 7 +- src/integrations/writer-lock.ts | 88 +++++ src/integrations/writer.ts | 157 +++++++- src/server/management/integration-routes.ts | 35 +- tests/cli-export-command.test.ts | 19 +- tests/cli-help.test.ts | 13 + .../client-config-export-new-clients.test.ts | 4 +- tests/client-config-export.test.ts | 104 +++++- tests/dsh-path-contract.test.ts | 63 ++++ tests/dsh-rc6-compat-script.test.ts | 67 ++++ tests/dsh-writer-lock.test.ts | 191 ++++++++++ .../fixtures/dsh-rc6-compat-e2e-settings.yaml | 23 ++ tests/fixtures/dsh-settings-0.1.0-rc.6.yaml | 24 ++ tests/integrations-invariants.test.ts | 16 +- tests/integrations-state.test.ts | 34 +- tests/integrations-writer.test.ts | 84 ++++- tests/management-client-config-route.test.ts | 24 ++ tests/management-integration-routes.test.ts | 61 ++++ tests/yaml-fragment-source.test.ts | 112 ++++++ 57 files changed, 2078 insertions(+), 221 deletions(-) create mode 100644 scripts/dsh-rc6-compat-e2e.ts create mode 100644 src/integrations/writer-lock.ts create mode 100644 tests/dsh-path-contract.test.ts create mode 100644 tests/dsh-rc6-compat-script.test.ts create mode 100644 tests/dsh-writer-lock.test.ts create mode 100644 tests/fixtures/dsh-rc6-compat-e2e-settings.yaml create mode 100644 tests/fixtures/dsh-settings-0.1.0-rc.6.yaml create mode 100644 tests/yaml-fragment-source.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index afeba687a..92e35af06 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code and Gajae Code from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code and DeepSeek Harness from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Seven clients work this way, each with a switch: +file, and removes it again. Eight clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -15,6 +15,13 @@ file, and removes it again. Seven clients work this way, each with a switch: | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | immediately, on a running gateway | `OPENCODEX_OPENCLAW_API_KEY` | | Kimi Code | `~/.kimi-code/config.toml` | TOML | on restart, or `/reload` | loopback placeholder | | Gajae Code | `~/.gjc/agent/models.yml` | YAML | new sessions, or when you open `/model` |`OPENCODEX_GAJAE_API_KEY` | +| DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | YAML | hot reload | non-secret loopback bearer placeholder | + +Managed DSH support has a compatibility floor of **DSH 0.1.0-rc.6**. OpenCodex owns only +`llm-pi-ai.providers.opencodex`; Apply and Refresh replace that fragment, Disable removes only that +fragment, and Restore puts back a recorded snapshot. DSH hot reloads provider changes. These +operations do not change the user's default model or the native `deepseek-official` provider. +The managed DSH integration is currently loopback-only and never writes a real credential. Paths honor each client's own environment override where it has one. For OMP, `OMP_PROFILE` wins over `PI_PROFILE` by presence, even when explicitly empty. A named profile @@ -85,7 +92,8 @@ than guessing which edits were yours. **Formatting is generally not preserved.** Applying parses a config and writes it back out, so JSON, JSON5 and TOML may be reformatted and comments in JSON5 or TOML are lost. -OMP is the exception: its YAML writer patches only `providers.opencodex`, preserving +OMP and DSH are the exceptions: their YAML writers patch only `providers.opencodex` and +`llm-pi-ai.providers.opencodex`, respectively, preserving unrelated provider comments and formatting byte-for-byte. If that exact source range cannot be identified safely, the operation refuses instead. For other clients, use Restore when you need the previous file bytes: the snapshot is a verbatim copy. @@ -98,10 +106,11 @@ changed value and calling it success. You will see the file named and nothing on disk will have moved. Editing that file by hand still works; it is only our automatic rewrite that declines. -**Pi, Kimi Code and Gajae Code only work against a loopback bind.** Their config schemas -have no place for the `x-opencodex-api-key` header that a non-loopback bind requires, so a -generated config would simply be rejected. Give them loopback access instead, through an -SSH tunnel or a local forwarder that adds the header. +**Pi, Kimi Code, Gajae Code and the managed DSH integration only work against a loopback bind.** +The first three have no config field for the `x-opencodex-api-key` header a non-loopback bind +requires. DSH has a generic headers map, but rc.6 does not document that dedicated admission +header as a supported integration contract, so the managed writer fails closed instead of +guessing. Give them loopback access through an SSH tunnel or a local forwarder that adds the header. **The generated OMP integration is also deliberately loopback-only.** OMP does support provider-level headers, but this initial integration does not emit remote diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 0e52fa125..642a1b5fa 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -125,7 +125,7 @@ Grok Build モデル フェンスを管理および適用します。 ## クライアント設定のエクスポート -### `ocx export --client ` +### `ocx export --client ` 実行中のプロキシに接続するクライアント設定を出力します。このコマンドは、ベース URL、モデル一覧、およびクライアントに応じた認証情報参照または `opencodex-loopback` プレースホルダーを含む `opencodex` プロバイダーブロックを、選択したクライアントのネイティブ形式でシリアル化します。 @@ -133,7 +133,7 @@ Grok Build モデル フェンスを管理および適用します。 |旗 |アクション | | --- | --- | -| `--client ` |必須。クライアントの設定形式を選択します。 | +| `--client ` |必須。クライアントの設定形式を選択します。 | | `--json` |構成 JSON のみを標準出力に出力するため、リダイレクトはバイト正確な出力をキャプチャします。 `--out` 書き込みメモを含むすべての診断は stderr に送られます。 | | `--out ` |設定を `` に書き込みます。既存のファイルの置き換えを拒否します。 | | `--force` | `--out` が既存のファイルを置き換えることを許可します。 | @@ -156,6 +156,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | なし - loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | +| `dsh` | `$DSH_HOME/settings.yaml`(既定 `~/.dsh/settings.yaml`) | `settings.yaml` | なし — 秘密ではないループバック bearer プレースホルダー | opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex が生成する Pi のエクスポートには環境変数が不要で、リテラルのプレースホルダー `opencodex-loopback` が入ります。この値は必須です。Pi はモデル リストを構築する際に `apiKey` を解決し、既存の設定に未設定の環境変数参照がある場合はプロバイダー全体を隠すためです。ループバックでは、生成されたプレースホルダーをプロキシが検査することはありません。 @@ -163,7 +164,7 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex `ocx export` は実際のクライアント設定を書き込むことはありません。宛先は手動でマージできるように出力されます。`--out` は、`--force` なしで既存のファイルを上書きすることを拒否します。これは、設定を置き換えると、その中にすでに含まれている他のプロバイダー、エージェント、および MCP エントリが破壊されるためです。 ::: -キーはシリアル化されません。opencode、Hermes、OpenClaw の設定には環境参照のみが含まれるためシークレットは環境内に残り、Pi、OMP、Kimi、Gajae の設定には認証情報ではなくループバック用プレースホルダーが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。生成される Pi、OMP、Kimi、Gajae 統合はループバック専用です。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 +キーはシリアル化されません。opencode、Hermes、OpenClaw の設定には環境参照のみが含まれるためシークレットは環境内に残り、Pi、OMP、Kimi、Gajae、DSH の設定には認証情報ではなくループバック用プレースホルダーが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。生成される Pi、OMP、Kimi、Gajae、DSH 統合はループバック専用です。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 同じペイロードが `GET /api/client-config` によって提供され、ダッシュボードの [API] タブにレンダリングされるため、CLI、API、および GUI は同じバイトを使用します。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 6b63616f6..36584c1ce 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -131,7 +131,7 @@ Grok Build model fence를 관리하고 적용합니다. ## 클라이언트 설정 내보내기 -### `ocx export --client ` +### `ocx export --client ` 실행 중인 프록시에 연결할 client config를 출력합니다. 이 명령은 base URL, model list, 그리고 client에 따라 credential reference 또는 `opencodex-loopback` placeholder를 포함한 `opencodex` provider block을 선택한 client의 네이티브 형식으로 직렬화합니다. @@ -139,7 +139,7 @@ Grok Build model fence를 관리하고 적용합니다. | 플래그 | 동작 | | --- | --- | -| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | +| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | | `--json` | config JSON만 stdout에 출력하므로, redirect가 byte-exact 출력을 캡처합니다. `--out` write note를 포함한 모든 진단 메시지는 stderr로 갑니다. | | `--out ` | config를 ``에 씁니다. 기존 파일이 있으면 덮어쓰지 않습니다. | | `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. | @@ -162,6 +162,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | 없음 - loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | +| `dsh` | `$DSH_HOME/settings.yaml`(기본값 `~/.dsh/settings.yaml`) | `settings.yaml` | 없음 — 비밀이 아닌 loopback bearer placeholder | opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 생성한 Pi 블록에는 환경 변수가 필요 없으며, 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 이 값은 필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하고, 기존 config에 설정되지 않은 env 참조가 있으면 provider 전체를 숨기기 때문입니다. 루프백에서 proxy는 생성된 placeholder를 검사하지 않습니다. @@ -169,7 +170,7 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 `ocx export`는 실제 client config를 절대 쓰지 않습니다. 대상 경로는 손으로 병합하라고 출력되며, `--out`은 `--force` 없이 기존 파일을 덮어쓰지 않습니다. config를 바꾸어 덮어쓰면 이미 들어 있던 다른 provider, agent, MCP entry가 사라지기 때문입니다. ::: -어떤 key도 직렬화되지 않습니다. opencode, Hermes, OpenClaw config에는 env reference만 들어가므로 secret은 환경 변수에 남고, Pi, OMP, Kimi, Gajae config에는 인증 정보가 아니라 loopback placeholder가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. 생성되는 Pi, OMP, Kimi, Gajae 통합은 loopback 전용입니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. +어떤 key도 직렬화되지 않습니다. opencode, Hermes, OpenClaw config에는 env reference만 들어가므로 secret은 환경 변수에 남고, Pi, OMP, Kimi, Gajae, DSH config에는 인증 정보가 아니라 loopback placeholder가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. 생성되는 Pi, OMP, Kimi, Gajae, DSH 통합은 loopback 전용입니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. 같은 payload는 `GET /api/client-config`로 제공되고 dashboard의 API 탭에도 렌더링되므로, CLI, API, GUI가 모두 같은 바이트를 사용합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 37508e129..c773666b0 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -168,7 +168,7 @@ Manage and apply the Grok Build model fence. ## Client config export -### `ocx export --client ` +### `ocx export --client ` Print a client config wired to the running proxy. The command serializes the `opencodex` provider block — base URL, model list, and the client's credential @@ -179,7 +179,7 @@ models Codex can currently see. | Flag | Action | | --- | --- | -| `--client ` | Required. Selects the client config dialect. | +| `--client ` | Required. Selects the client config dialect. | | `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. | | `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. | | `--force` | Allow `--out` to replace an existing file. | @@ -204,6 +204,11 @@ client applies its own defaults for those). | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | none — loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | +| `dsh` | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | `settings.yaml` | none — non-secret loopback bearer placeholder | + +The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only +`llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and +`deepseek-official` remain untouched. This export is loopback-only and carries no real credential. opencode interpolates `{env:OPENCODEX_OPENCODE_API_KEY}`. The generated Pi and OMP exports do not require an environment variable: each carries the literal `opencodex-loopback` placeholder. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index c73ac0205..4d2e604d3 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -152,7 +152,7 @@ override, но файлы на диске никогда не меняются. ## Экспорт client config -### `ocx export --client ` +### `ocx export --client ` Печатает client config, направленный на работающий прокси. Команда сериализует блок провайдера `opencodex` в нативном формате выбранного клиента: base URL, список моделей и, @@ -163,7 +163,7 @@ override, но файлы на диске никогда не меняются. | Флаг | Действие | | --- | --- | -| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | +| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | | `--json` | Печатать только JSON-конфиг в stdout, чтобы redirect сохранял побайтно точный вывод. Вся диагностика, включая заметку о записи через `--out`, идёт в stderr. | | `--out ` | Записать конфиг в ``. Перезаписывать существующий файл не позволит. | | `--force` | Разрешить `--out` заменить существующий файл. | @@ -189,6 +189,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | нет — loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | +| `dsh` | `$DSH_HOME/settings.yaml` (по умолчанию `~/.dsh/settings.yaml`) | `settings.yaml` | нет — несекретная loopback bearer-заглушка | opencode интерполирует `{env:OPENCODEX_OPENCODE_API_KEY}`. Сгенерированный opencodex экспорт для Pi не требует переменной окружения и несёт литеральную заглушку `opencodex-loopback`. Это значение @@ -204,11 +205,11 @@ MCP-записи. ::: Никакой ключ никогда не сериализуется. Конфиги opencode, Hermes и OpenClaw несут только -env-reference, так что секрет остаётся в вашем окружении, а конфиги Pi, OMP, Kimi и Gajae несут +env-reference, так что секрет остаётся в вашем окружении, а конфиги Pi, OMP, Kimi, Gajae и DSH несут loopback-заглушку вместо учётных данных. Loopback-прокси (`127.0.0.1`, по умолчанию) вообще не требует admission key. Если прокси слушает не на loopback, задайте соответствующую переменную `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY` или `OPENCODEX_OPENCLAW_API_KEY`. -Сгенерированные интеграции Pi, OMP, Kimi и Gajae работают только через loopback. Как выдаются +Сгенерированные интеграции Pi, OMP, Kimi, Gajae и DSH работают только через loopback. Как выдаются admission key, описано в [Удалённом доступе](/reference/configuration/#remote-access). Ключи upstream-провайдеров — это совсем отдельная история и настраиваются в [Провайдерах](/guides/providers/). diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 57d6d6b40..88de3e041 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code ve Gajae Code'u opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code ve DeepSeek Harness'ı opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. Yedi istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. Sekiz istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -16,6 +16,14 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. Yedi istemci bu şekilde | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | hemen, çalışan bir ağ geçidinde | `OPENCODEX_OPENCLAW_API_KEY` | | Kimi Code | `~/.kimi-code/config.toml` | TOML | yeniden başlatmada veya `/reload` ile | geri döngü (loopback) yer tutucusu | | Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | +| DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | + +Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca +`llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı +Bırak yalnızca bu bölümü kaldırır, Geri Yükle ise kaydedilmiş bir anlık görüntüyü geri koyar. DSH +sağlayıcı değişikliklerini çalışırken yeniden yükler. Bu işlemler kullanıcının varsayılan modelini +veya yerel `deepseek-official` sağlayıcısını değiştirmez. Yönetilen DSH entegrasyonu şu anda yalnızca +geri döngü içindir ve asla gerçek bir kimlik bilgisi yazmaz. Yollar, varsa her istemcinin kendi ortam geçersiz kılmalarını dikkate alır. OMP için `OMP_PROFILE`, açıkça boş olduğunda bile varlığıyla `PI_PROFILE`'a üstün @@ -100,8 +108,9 @@ size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. **Biçimlendirme genellikle korunmaz.** Uygulama işlemi bir yapılandırmayı ayrıştırır ve geri yazar, bu nedenle JSON, JSON5 ve TOML yeniden -biçimlendirilebilir ve JSON5 veya TOML içindeki yorumlar kaybolur. OMP -istisnadır: YAML yazıcısı yalnızca `providers.opencodex` kısmını yamalar, +biçimlendirilebilir ve JSON5 veya TOML içindeki yorumlar kaybolur. OMP ve DSH +istisnadır: YAML yazıcıları sırasıyla yalnızca `providers.opencodex` ve +`llm-pi-ai.providers.opencodex` kısımlarını yamalar, ilgisiz sağlayıcı yorumlarını ve biçimlendirmesini bayt bayt korur. Bu tam kaynak aralığı güvenli bir şekilde tanımlanamazsa işlem bunun yerine reddeder. Diğer istemciler için önceki dosya baytlarına ihtiyacınız olduğunda Geri @@ -115,12 +124,12 @@ değişen bir değer yazıp buna başarı demek yerine durur ve bunu söyler. Do adlandırıldığını ve diskte hiçbir şeyin taşınmadığını görürsünüz. Bu dosyayı elle düzenlemek hala çalışır; yalnızca otomatik yeniden yazmamız reddeder. -**Pi, Kimi Code ve Gajae Code yalnızca geri döngü (loopback) bağlantısına karşı -çalışır.** Yapılandırma şemalarında geri döngü olmayan bir bağlantının -gerektirdiği `x-opencodex-api-key` başlığı için yer yoktur, bu nedenle -oluşturulan bir yapılandırma basitçe reddedilir. Bunun yerine onlara bir SSH -tüneli veya başlığı ekleyen yerel bir iletici aracılığıyla geri döngü erişimi -verin. +**Pi, Kimi Code, Gajae Code ve yönetilen DSH entegrasyonu yalnızca geri döngü (loopback) bağlantısına karşı +çalışır.** İlk üçünün yapılandırmasında geri döngü olmayan bir bağlantının gerektirdiği +`x-opencodex-api-key` başlığı için alan yoktur. DSH genel bir headers haritası sunar, ancak rc.6 +bu özel kabul başlığını desteklenen bir entegrasyon sözleşmesi olarak belgelememektedir; bu nedenle +yönetilen writer tahmin yürütmek yerine kapalı biçimde reddeder. Bunun yerine bir SSH tüneli veya +başlığı ekleyen yerel bir iletici aracılığıyla geri döngü erişimi verin. **Oluşturulan OMP entegrasyonu da kasıtlı olarak yalnızca geri döngü içindir.** OMP sağlayıcı düzeyinde başlıkları destekler, ancak bu ilk entegrasyon uzak @@ -157,5 +166,3 @@ değiştiyse, komut reddeder ve size bildirir; çünkü daha yeni düzenlemeleri doğrulanmıştır; neyin ne zaman denetlendiğine ilişkin `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` içindeki araştırma notlarına bakın. - - diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 3be8cb76a..d92a1f07a 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -191,7 +191,7 @@ Grok Build model çitini yönetin ve uygulayın. ## İstemci yapılandırma dışa aktarma -### `ocx export --client ` +### `ocx export --client ` Çalışan proxy'ye bağlı bir istemci yapılandırmasını yazdırın. Komut, `opencodex` sağlayıcı bloğunu — temel URL, model listesi ve istemcinin kimlik bilgisi @@ -203,7 +203,7 @@ yalnızca Codex'in şu anda görebildiği modelleri yayınlar. | Bayrak | Eylem | | --- | --- | -| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | +| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | | `--json` | Betikler için stdout üzerinde oluşturulan belgeyi JSON olarak yazdırın. Bu, seçilen istemcinin yerel formatı YAML, TOML veya JSON5 olsa bile JSON'dur. | | `--out ` | İstemcinin yerel yapılandırma formatını `` konumuna yazın. Mevcut bir dosyanın üzerine yazmayı reddeder. | | `--force` | `--out`'un mevcut bir dosyanın üzerine yazmasına izin verin. | @@ -229,6 +229,7 @@ için kendi varsayılanlarını uygular) gelir. | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | yok — geri döngü yer tutucusu | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | +| `dsh` | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | `settings.yaml` | yok — gizli olmayan geri döngü bearer yer tutucusu | opencode `{env:OPENCODEX_OPENCODE_API_KEY}` değerini enterpole eder. Üretilen Pi ve OMP dışa aktarımları bir ortam değişkeni gerektirmez: her biri değişmez @@ -275,4 +276,3 @@ Doğrulanmış OpenCodex yapılandırmasını inceleyin ve güvenle değiştirin ve `get` sırları maskeler. İçe aktarma yazmadan önce doğrular ve `--yes` gerektirir. - diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 6d4de4f9f..5fc62efb3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -132,7 +132,7 @@ ocx claude desktop import [--apply] Validate and import JSON ## Client config export -### `ocx export --client ` +### `ocx export --client ` 输出连接到正在运行代理的客户端配置。此命令会以所选客户端的原生格式序列化 `opencodex` provider 块,其中包含基础 URL、模型列表,以及该客户端适用的凭据引用或 `opencodex-loopback` 占位值。 @@ -140,7 +140,7 @@ ocx claude desktop import [--apply] Validate and import JSON | 标志 | 动作 | | --- | --- | -| `--client ` | 必需。选择客户端配置格式。 | +| `--client ` | 必需。选择客户端配置格式。 | | `--json` | 仅在 stdout 打印配置 JSON,这样重定向即可捕获字节级精确输出。包括 `--out` 写入提示在内的所有诊断信息都会输出到 stderr。 | | `--out ` | 将配置写入 ``。拒绝替换已存在的文件。 | | `--force` | 允许 `--out` 替换已存在的文件。 | @@ -163,6 +163,7 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | | `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | 无 - loopback placeholder | | `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | +| `dsh` | `$DSH_HOME/settings.yaml`(默认 `~/.dsh/settings.yaml`) | `settings.yaml` | 无 — 非秘密环回 bearer 占位值 | opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 导出不需要环境变量,而是携带字面占位值 `opencodex-loopback`。这个值是必需的:Pi 在构建模型列表时会解析 `apiKey`,如果已有配置包含未设置的环境变量引用,它就会隐藏整个 provider。回环上的代理从不校验生成的占位值。 @@ -170,7 +171,7 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi `ocx export` 从不写入你的真实客户端配置。该命令只会打印目标路径供你手动合并,而 `--out` 在没有 `--force` 的情况下拒绝覆盖已有文件,因为替换配置会破坏其中已有的其他 providers、agents 和 MCP 条目。 ::: -任何密钥都不会被序列化。opencode、Hermes 和 OpenClaw 配置里只包含环境引用,因此密钥仍保留在你的环境中;Pi、OMP、Kimi 和 Gajae 配置里携带的是环回占位值而不是任何凭据。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。生成的 Pi、OMP、Kimi 和 Gajae 集成仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 +任何密钥都不会被序列化。opencode、Hermes 和 OpenClaw 配置里只包含环境引用,因此密钥仍保留在你的环境中;Pi、OMP、Kimi、Gajae 和 DSH 配置里携带的是环回占位值而不是任何凭据。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。生成的 Pi、OMP、Kimi、Gajae 和 DSH 集成仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 同一份负载会通过 `GET /api/client-config` 提供,并在仪表盘的 API 选项卡中渲染,因此 CLI、API 和 GUI 使用的是同一字节内容。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 19057a0e6..0f9de23e2 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code 與 Gajae Code——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code 與 DeepSeek Harness——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有七個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有八個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -14,6 +14,13 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | 立即,在執行中的 gateway 上 | `OPENCODEX_OPENCLAW_API_KEY` | | Kimi Code | `~/.kimi-code/config.toml` | TOML | 重新啟動時,或 `/reload` | loopback 佔位符 | | Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | +| DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | + +受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 +`llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段, +Restore 則放回已記錄的快照。DSH 會熱重載 provider 變更。這些操作不會改動使用者的 +預設模型,也不會改動原生 `deepseek-official` provider。受管理 DSH 整合目前僅支援 +loopback,而且絕不會寫入真實憑證。 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 @@ -41,11 +48,11 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil ## 誠實的預期 -**格式通常不會被保留。** 套用會解析設定並重新寫出,所以 JSON、JSON5 與 TOML 可能被重新格式化,JSON5 或 TOML 中的註解會遺失。OMP 是例外:它的 YAML writer 只修補 `providers.opencodex`,逐位元組保留無關的 provider 註解與格式。如果無法安全地識別那個確切的來源範圍,操作會拒絕執行。對其他客戶端,當你需要先前的檔案位元組時請使用 Restore:快照是逐字的副本。 +**格式通常不會被保留。** 套用會解析設定並重新寫出,所以 JSON、JSON5 與 TOML 可能被重新格式化,JSON5 或 TOML 中的註解會遺失。OMP 與 DSH 是例外:它們的 YAML writer 分別只修補 `providers.opencodex` 與 `llm-pi-ai.providers.opencodex`,逐位元組保留無關的 provider 註解與格式。如果無法安全地識別那個確切的來源範圍,操作會拒絕執行。對其他客戶端,當你需要先前的檔案位元組時請使用 Restore:快照是逐字的副本。 **如果某個值無法忠實重寫,開關會拒絕執行。** 往返覆蓋這些格式在實務上會用到的值種類;當它做不到時——例如使用 `inf` 或 `nan` 的 TOML 檔案,我們可用的 parser 無法準確讀回——套用會停止並說明,而不是寫入被改動的值然後宣稱成功。你會看到檔案被指名,磁碟上沒有任何東西被移動。手動編輯那個檔案仍然有效;只有我們的自動重寫會拒絕。 -**Pi、Kimi Code 與 Gajae Code 只能對 loopback bind 運作。** 它們的設定 schema 沒有非 loopback bind 所需的 `x-opencodex-api-key` header 的位置,所以產生的設定只會被拒絕。改用 loopback 存取,透過 SSH tunnel 或加入該 header 的本機 forwarder。 +**Pi、Kimi Code、Gajae Code 與受管理 DSH 整合只能對 loopback bind 運作。** 前三者的設定沒有非 loopback bind 所需的 `x-opencodex-api-key` header 欄位。DSH 雖然提供通用 headers map,但 rc.6 並未把這個專用准入 header 記錄為受支援的整合契約,因此受管理 writer 會選擇安全拒絕,而不自行猜測。請改用 SSH tunnel,或由本機 forwarder 加上該 header 後再以 loopback 存取。 **產生的 OMP 整合也刻意只支援 loopback。** OMP 確實支援 provider 層級的 headers,但這個最初的整合不會發出遠端 `x-opencodex-api-key` 憑證連線。手動的遠端 OMP 設定目前不在受管理的整合範圍內。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 84357e03c..71c47037b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -130,15 +130,15 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ## 客戶端設定匯出 -### `ocx export --client ` +### `ocx export --client ` -印出連接到執行中代理的客戶端設定。opencode 與 [Pi](/zh-tw/guides/pi/) 從其自身的 JSON 設定而非環境變數讀取供應商,因此此指令將 `opencodex` 供應商區塊——base URL、模型清單與客戶端的環境變數參考——序列化,供你合併到該檔案中。 +印出連接到執行中代理的客戶端設定。此指令會用所選客戶端的原生格式,序列化含有 base URL、模型清單,以及適用的環境變數參考或 loopback 佔位符的 `opencodex` provider 區塊。 代理必須正在執行;指令解析其即時連接埠、讀取 `/api/models`,並只輸出 Codex 目前可見的模型。 | 旗標 | 動作 | | --- | --- | -| `--client ` | 必填。選擇客戶端方言:opencode 的 keyed `provider` 物件或 Pi 的 `providers` 陣列。 | +| `--client ` | 必填。選擇客戶端設定格式。 | | `--json` | 僅在 stdout 印出設定 JSON,使重導向能擷取逐位元組輸出。所有診斷訊息(含 `--out` 寫入提示)皆送至 stderr。 | | `--out ` | 將設定寫入 ``。拒絕覆寫既有檔案。 | | `--force` | 允許 `--out` 覆寫既有檔案。 | @@ -146,24 +146,33 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ```bash ocx export --client opencode # 設定加上目的地、合併警告與計數 ocx export --client pi --json > pi-models.json # 供 pipe 或 diff 用的逐位元組 JSON +ocx export --client omp --out ./omp-models.yml # 原生 OMP YAML ocx export --client opencode --out ~/opencodex-opencode.json ``` -未指定 `--json` 時,JSON 在前,接著是標準目的地路徑、合併警告、環境匯出行,以及附帶有多少列省略 context limit 的模型計數(客戶端會對那些套用自身預設值)。 +未指定 `--json` 時,會先輸出客戶端的原生設定格式,接著是標準目的地路徑、合併警告、客戶端專屬提示,以及附帶有多少列省略 context limit 的模型計數(客戶端會對那些套用自身預設值)。 | 客戶端 | 標準目的地 | 下載檔名 | 環境變數 | | --- | --- | --- | --- | | `opencode` | `~/.config/opencode/opencode.json`(`XDG_CONFIG_HOME` 設定時優先) | `opencode.json` | `OPENCODEX_OPENCODE_API_KEY` | -| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | `OPENCODEX_API_KEY` | - -兩個環境變數名稱不同,且每個客戶端只插值自己的。opencode 讀取 -`{env:OPENCODEX_OPENCODE_API_KEY}`;Pi 讀取 `$OPENCODEX_API_KEY`。 +| `pi` | `~/.pi/agent/models.json` | `pi-models.json` | 無——區塊帶有字面值 `opencodex-loopback` | +| `omp` | `~/.omp/agent/models.yml`(即使是空值,`OMP_PROFILE` 仍優先於 `PI_PROFILE`) | `omp-models.yaml` | 無——loopback 佔位符 | +| `hermes` | `~/.hermes/config.yaml` | `hermes-config.yaml` | `OPENCODEX_HERMES_API_KEY` | +| `openclaw` | `~/.openclaw/openclaw.json` | `openclaw.json5` | `OPENCODEX_OPENCLAW_API_KEY` | +| `kimi` | `~/.kimi-code/config.toml` | `kimi-config.toml` | 無——loopback 佔位符 | +| `gajae` | `~/.gjc/agent/models.yml` | `gajae-models.yaml` | `OPENCODEX_GAJAE_API_KEY` | +| `dsh` | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | `settings.yaml` | 無——非秘密的 loopback bearer 佔位符 | + +opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不需要環境變數, +而是帶有字面值 `opencodex-loopback`。DSH 匯出需要 DSH 0.1.0-rc.6 或更新版本,且只擁有 +`llm-pi-ai.providers.opencodex`。DSH 會熱重載該 provider;使用者的預設模型與 +`deepseek-official` 維持不變。這項匯出僅支援 loopback,且不含真實憑證。 :::caution[合併,而非取代] `ocx export` 永不寫入你的真實客戶端設定。目的地僅印出供你手動合併,而 `--out` 在沒有 `--force` 時拒絕覆寫既有檔案,因為取代設定檔會毀掉其中已有的其他供應商、代理與 MCP 項目。 ::: -金鑰永不被序列化。設定只帶有客戶端的環境變數參考,因此秘密留在你的環境中。回送代理(`127.0.0.1`,預設值)完全不需要許可金鑰——該參考只是未被使用。僅在代理綁定超出回送時才設定該變數;關於許可金鑰的簽發方式,請見[遠端存取](/zh-tw/reference/configuration/#remote-access)。上游供應商本身的金鑰是完全不同的事,依[供應商](/zh-tw/guides/providers/)個別設定。 +金鑰永不被序列化。設定只帶有文件化的環境變數參考,或非秘密的 loopback 佔位符。loopback 代理(`127.0.0.1`,預設值)完全不需要准入金鑰。只有客戶端 schema 支援、且代理綁定超出 loopback 時,才設定被引用的變數;關於准入金鑰的簽發方式,請見[遠端存取](/zh-tw/reference/configuration/#remote-access)。上游 provider 本身的金鑰是完全不同的事,依[供應商](/zh-tw/guides/providers/)個別設定。 相同的 payload 亦由 `GET /api/client-config` 提供,並在儀表板的 API 分頁渲染,因此 CLI、API 與 GUI 使用相同的位元組。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index b93b19724..52d6db4d8 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -130,7 +130,7 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | --- | --- | --- | | `GET /api/catalog` | 回傳已安裝的 Codex 目錄檔案 | 404 目錄未找到 | | `GET /api/models` | 回傳儀表板/CLI 模型列 | 收集飽和時 `catalog_busy` | -| `GET /api/client-config?client=...` | 建構唯讀的 OpenCode 或 Pi 客戶端設定檔案 | 400 不支援客戶端;503 目錄不可用 | +| `GET /api/client-config?client=...` | 為 `opencode`、`pi`、`omp`、`hermes`、`openclaw`、`kimi`、`gajae` 或 `dsh` 建構唯讀客戶端設定 | 400 不支援客戶端;503 目錄不可用 | | `PUT /api/disabled-models` | 取代共享的 disabled-model 清單 | 400 無效 JSON | | `PUT /api/model-visibility` | 原子地變更供應商或模型層級可見性 | 400 無效供應商、scope、目標或 body | | `GET, POST /api/custom-models` | 列出自訂模型或新增一個 | 400 無效欄位;404 供應商缺失;409 重複模型 | diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 306a7c79f..f85e8b1ad 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -89,6 +89,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/openclaw", "integrations/kimi", "integrations/gajae", + "integrations/dsh", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index dc98871db..d1527a5b1 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -19,6 +19,7 @@ export const CLIENT_LABEL_KEYS = { openclaw: "api.clientConfig.clientOpenclaw", kimi: "api.clientConfig.clientKimi", gajae: "api.clientConfig.clientGajae", + dsh: "api.clientConfig.clientDsh", } as const; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1ddf5a014..f4e25636e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -812,6 +812,7 @@ export const de: Record = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Die Codex-Anbindung wird vom Proxy-Dienst verwaltet. Beim Start von opencodex wird sie angewendet; beim Stoppen des Dienstes wird das native Routing wiederhergestellt.", "integrations.codex.openService": "Dienststeuerung öffnen", @@ -922,6 +923,7 @@ export const de: Record = { "integrations.semantics.openclaw": "Wird sofort auf ein laufendes Gateway angewendet.", "integrations.semantics.kimi": "Zum Anwenden neu starten oder /reload ausführen (v2 überwacht die Datei).", "integrations.semantics.gajae": "Gilt für eine neue Sitzung oder beim Öffnen von /model.", + "integrations.semantics.dsh": "OpenCodex verwaltet nur llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH lädt diesen Anbieter im laufenden Betrieb neu; Ihr Standardmodell und deepseek-official bleiben unverändert. Derzeit nur über Loopback; es werden keine echten Zugangsdaten geschrieben.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1203,6 +1205,7 @@ export const de: Record = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2d2fc9660..11338b919 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1280,6 +1280,7 @@ export const en = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex wiring is owned by the proxy service. Starting opencodex applies it; stopping the service restores native routing.", "integrations.codex.openService": "Open service controls", @@ -1390,6 +1391,7 @@ export const en = { "integrations.semantics.openclaw": "Applies immediately to a running gateway.", "integrations.semantics.kimi": "Restart or run /reload to apply it (v2 watches the file).", "integrations.semantics.gajae": "Applies to a new session or when opening /model.", + "integrations.semantics.dsh": "OpenCodex manages only llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH hot reloads this provider; your default model and deepseek-official stay unchanged. Currently loopback-only; no real credential is written.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -1679,6 +1681,7 @@ export const en = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d87f58f9d..c4bc0321c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1222,6 +1222,7 @@ export const ja: Record = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex の接続はプロキシサービスが管理します。opencodex を起動すると適用され、サービスを停止するとネイティブのルーティングに戻ります。", "integrations.codex.openService": "サービス制御を開く", @@ -1332,6 +1333,7 @@ export const ja: Record = { "integrations.semantics.openclaw": "実行中のゲートウェイにすぐ適用されます。", "integrations.semantics.kimi": "再起動するか /reload を実行すると適用されます(v2 はファイルを監視します)。", "integrations.semantics.gajae": "新しいセッション、または /model を開いたときに適用されます。", + "integrations.semantics.dsh": "OpenCodex が管理するのは $DSH_HOME/settings.yaml 内の llm-pi-ai.providers.opencodex だけです。DSH はこのプロバイダーをホットリロードし、既定のモデルと deepseek-official は変更しません。現在はループバック専用で、実際の認証情報は書き込みません。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -1618,6 +1620,7 @@ export const ja: Record = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index bbf55e907..e717e7a5c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -836,6 +836,7 @@ export const ko: Record = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 연결은 프록시 서비스가 관리합니다. opencodex를 시작하면 적용되고 서비스를 중지하면 기본 라우팅으로 복원됩니다.", "integrations.codex.openService": "서비스 제어 열기", @@ -946,6 +947,7 @@ export const ko: Record = { "integrations.semantics.openclaw": "실행 중인 게이트웨이에 즉시 반영됩니다.", "integrations.semantics.kimi": "재시작 또는 /reload 시 적용됩니다 (v2는 파일 변경을 감지합니다).", "integrations.semantics.gajae": "새 세션 또는 /model을 열 때 적용됩니다.", + "integrations.semantics.dsh": "OpenCodex는 $DSH_HOME/settings.yaml의 llm-pi-ai.providers.opencodex만 관리합니다. DSH는 이 provider를 hot reload하며 기본 model과 deepseek-official은 변경하지 않습니다. 현재 loopback 전용이며 실제 credential을 기록하지 않습니다.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1230,6 +1232,7 @@ export const ko: Record = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 60064fd39..b6f8c390d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1264,6 +1264,7 @@ export const ru: Record = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Подключением Codex управляет прокси-сервис. При запуске opencodex оно применяется, а при остановке сервиса восстанавливается нативная маршрутизация.", "integrations.codex.openService": "Открыть управление сервисом", @@ -1374,6 +1375,7 @@ export const ru: Record = { "integrations.semantics.openclaw": "Немедленно применяется к работающему шлюзу.", "integrations.semantics.kimi": "Чтобы применить, перезапустите клиент или выполните /reload (v2 отслеживает файл).", "integrations.semantics.gajae": "Применяется в новом сеансе или при открытии /model.", + "integrations.semantics.dsh": "OpenCodex управляет только llm-pi-ai.providers.opencodex в $DSH_HOME/settings.yaml. DSH применяет этот провайдер горячей перезагрузкой; модель по умолчанию и deepseek-official остаются без изменений. Сейчас поддерживается только loopback; реальные учётные данные не записываются.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -1660,6 +1662,7 @@ export const ru: Record = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 32b11d619..434805749 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1271,6 +1271,7 @@ export const tr: Record = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex bağlantısı proxy servisine aittir.", "integrations.codex.openService": "Servis kontrollerini aç", @@ -1380,6 +1381,7 @@ export const tr: Record = { "integrations.semantics.openclaw": "Anında uygulanır.", "integrations.semantics.kimi": "Uygulamak için yeniden başlatın.", "integrations.semantics.gajae": "Yeni oturuma uygulanır.", + "integrations.semantics.dsh": "OpenCodex yalnızca $DSH_HOME/settings.yaml içindeki llm-pi-ai.providers.opencodex bölümünü yönetir. DSH bu sağlayıcıyı çalışırken yeniden yükler; varsayılan modeliniz ve deepseek-official değişmez. Şimdilik yalnızca geri döngü desteklenir; gerçek kimlik bilgisi yazılmaz.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -1667,6 +1669,7 @@ export const tr: Record = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 3edb42ebb..e194a06e2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1785,6 +1785,7 @@ export const zhTW: Record = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 連線由代理服務管理。啟動 opencodex 時套用;停止服務時還原原生路由。", "integrations.codex.openService": "開啟服務控制", @@ -1895,6 +1896,7 @@ export const zhTW: Record = { "integrations.semantics.openclaw": "立即套用到正在執行的閘道。", "integrations.semantics.kimi": "重新啟動或執行 /reload 以套用(v2 會監視該檔案)。", "integrations.semantics.gajae": "在新工作階段中或開啟 /model 時生效。", + "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 會熱重載該 provider;你的預設模型與 deepseek-official 維持不變。目前僅支援 loopback,且不會寫入真實憑證。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -1931,6 +1933,7 @@ export const zhTW: Record = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 899e1fd6c..5d198f5be 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -829,6 +829,7 @@ export const zh: Record = { "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", + "integrations.tab.dsh": "DeepSeek Harness (DSH)", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 连接由代理服务管理。启动 opencodex 时应用该连接;停止服务时恢复原生路由。", "integrations.codex.openService": "打开服务控制", @@ -939,6 +940,7 @@ export const zh: Record = { "integrations.semantics.openclaw": "立即应用到正在运行的网关。", "integrations.semantics.kimi": "重启或运行 /reload 以应用(v2 会监视该文件)。", "integrations.semantics.gajae": "在新会话中或打开 /model 时生效。", + "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 会热重载该 provider;你的默认模型和 deepseek-official 保持不变。目前仅支持环回地址,且不会写入真实凭据。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1223,6 +1225,7 @@ export const zh: Record = { "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 27584595d..4cdb78a97 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -36,6 +36,7 @@ const TABS: readonly TabDefinition[] = [ { id: "openclaw", hash: "integrations/openclaw", labelKey: "integrations.tab.openclaw" }, { id: "kimi", hash: "integrations/kimi", labelKey: "integrations.tab.kimi" }, { id: "gajae", hash: "integrations/gajae", labelKey: "integrations.tab.gajae" }, + { id: "dsh", hash: "integrations/dsh", labelKey: "integrations.tab.dsh" }, ] as const; const FILE_CLIENTS = new Set([ @@ -46,6 +47,7 @@ const FILE_CLIENTS = new Set([ "openclaw", "kimi", "gajae", + "dsh", ]); function readIntegrationTab(hash = window.location.hash): IntegrationTab { diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 4783a4ef2..ead9e8cc9 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -24,6 +24,7 @@ const SEMANTICS_KEY: Record = { openclaw: "integrations.semantics.openclaw", kimi: "integrations.semantics.kimi", gajae: "integrations.semantics.gajae", + dsh: "integrations.semantics.dsh", }; const TAB_LABEL_KEY: Record = { @@ -34,6 +35,7 @@ const TAB_LABEL_KEY: Record = { openclaw: "integrations.tab.openclaw", kimi: "integrations.tab.kimi", gajae: "integrations.tab.gajae", + dsh: "integrations.tab.dsh", }; const KIND_KEY: Record = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 0d7b0d3ad..154ed1c6d 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -8,6 +8,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "openclaw", "kimi", "gajae", + "dsh", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index c2894c3cd..d17d20e13 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -2,7 +2,7 @@ * One row model for every client the Integrations page can reach. * * The overview used to read a single route, `/api/client-integrations`, which - * answers only for the seven file-toggle clients. A user with Claude Code + * answers only for the eight file-toggle clients. A user with Claude Code * connected and a Grok fence written was told "applied: 0" while three * integrations were live one tab away. Five more sources join the grid here, * each with its own payload shape, mapped to the badge vocabulary the file @@ -141,6 +141,7 @@ const FILE_LABEL_KEY: Record = { openclaw: "integrations.tab.openclaw", kimi: "integrations.tab.kimi", gajae: "integrations.tab.gajae", + dsh: "integrations.tab.dsh", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index c467a20b2..a50d2b948 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -12,7 +12,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; -import { CLIENTS } from "../src/components/apikeys-workspace/client-config-clients"; +import { + CLIENTS, + CLIENT_LABEL_KEYS, +} from "../src/components/apikeys-workspace/client-config-clients"; import { LanguageProvider } from "../src/i18n/provider"; import ClientConfigPanel from "../src/components/apikeys-workspace/ClientConfigPanel"; @@ -167,6 +170,11 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } +test("the API download surface includes DSH as the eighth client", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh"]); + expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); +}); + test("each row fetches its own client and its dialog renders that client's exact bytes", async () => { // Carries the guard the client-switch test owned: payload identity per client. // Destination-only assertions would have lost it. diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 29106661e..bc329a483 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nContext, type TFn } from "../src/i18n/shared"; import IntegrationStateBadge from "../src/pages/integrations/IntegrationStateBadge"; import { + FILE_INTEGRATION_CLIENTS, IntegrationApiError, loadIntegrationJournal, loadIntegrationState, @@ -15,6 +16,12 @@ import { const originalFetch = globalThis.fetch; +test("DSH is a file integration client", () => { + expect(FILE_INTEGRATION_CLIENTS).toEqual([ + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", + ]); +}); + afterEach(() => { globalThis.fetch = originalFetch; }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 92151ff36..0fb1208e5 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -137,7 +137,7 @@ test("file clients keep their existing badge and applied semantics", () => { expect(counts.stale).toBe(1); }); -test("every client counts toward the summary, not just the file seven", () => { +test("every client counts toward the summary, not just the file clients", () => { const rows = buildOverviewRows(sources({ clients: [fileStatus({ clientId: "opencode", state: "current" })], codex: { routingInjected: true, status: "at-risk" }, @@ -178,9 +178,14 @@ test("every client counts toward the summary, not just the file seven", () => { test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(11); + expect(built.rows).toHaveLength(12); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "kimi").state).toBe("unknown"); + expect(rowById(built, "dsh")).toMatchObject({ + hash: "integrations/dsh", + labelKey: "integrations.tab.dsh", + state: "unknown", + }); // Once settled, a client the server omitted is genuinely gone. const settled = buildOverviewRows(sources({ clients: [], clientsSettled: true })); @@ -196,4 +201,5 @@ test("each row points at its own tab", () => { expect(rowById(rows, "grok").hash).toBe("integrations/grok"); expect(rowById(rows, "hermes").hash).toBe("integrations/hermes"); expect(rowById(rows, "omp").hash).toBe("integrations/omp"); + expect(rowById(rows, "dsh").hash).toBe("integrations/dsh"); }); diff --git a/gui/tests/integrations-routing.test.ts b/gui/tests/integrations-routing.test.ts index ad26e3d50..f96dff5b7 100644 --- a/gui/tests/integrations-routing.test.ts +++ b/gui/tests/integrations-routing.test.ts @@ -84,6 +84,15 @@ describe("registered nested hashes", () => { expect(resolveAppHashChange("integrations/claude/desktop").replaceTo).toBeNull(); }); + test("the DSH deep link is registered and survives normalization", () => { + expect(INTEGRATION_TAB_HASHES).toContain("integrations/dsh"); + expect(readPageFromHash("integrations/dsh")).toBe("integrations"); + expect(resolveAppHashChange("integrations/dsh")).toEqual({ + page: "integrations", + replaceTo: null, + }); + }); + test("bare #integrations is Overview and has no suffix of its own", () => { expect(readPageFromHash("integrations")).toBe("integrations"); expect(hashBelongsToPage("integrations", "integrations")).toBe(true); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index a720859cd..bc1d445e5 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -164,7 +164,10 @@ afterEach(async () => { } }); -async function mountClient(active = true): Promise { +async function mountClient( + active = true, + client: "hermes" | "dsh" = "hermes", +): Promise { const [{ createRoot }, { LanguageProvider }, { default: FileIntegrationPage }] = await Promise.all([ import("react-dom/client"), import("../src/i18n/provider"), @@ -174,13 +177,30 @@ async function mountClient(active = true): Promise { root = createRoot(container); root.render( - + , ); }); await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 30)); }); } +test("the DSH surface uses localized ownership semantics and its own API route", async () => { + stateResponse = () => json(status({ + clientId: "dsh", + configPath: "/tmp/home/.dsh/settings.yaml", + })); + await mountClient(true, "dsh"); + + const text = container.textContent ?? ""; + expect(text).toContain("DeepSeek Harness (DSH)"); + expect(text).toContain("llm-pi-ai.providers.opencodex"); + expect(text).toContain("hot reload"); + expect(text).toContain("default model"); + expect(text).toContain("deepseek-official"); + expect(text).toContain("loopback"); + expect(requests.some(request => request.url.endsWith("/api/client-integrations/dsh"))).toBe(true); +}); + function buttons(): HTMLButtonElement[] { return Array.from(container.querySelectorAll("button")) as unknown as HTMLButtonElement[]; } diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 000640755..0b4ef86da 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -91,6 +91,7 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientOpenclaw", "api.clientConfig.clientKimi", "api.clientConfig.clientGajae", + "api.clientConfig.clientDsh", "codexAuth.codexApp", "codexAuth.creditNextBadge", "common.github", @@ -106,6 +107,7 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "integrations.tab.openclaw", "integrations.tab.kimi", "integrations.tab.gajae", + "integrations.tab.dsh", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", @@ -162,3 +164,56 @@ test("every locale key set matches the English source", async () => { expect(other).toEqual(en); } }); + +const DSH_VISIBLE_COPY: Record<(typeof LOCALES)[number], readonly [string, string, string]> = { + en: [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex manages only llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH hot reloads this provider; your default model and deepseek-official stay unchanged. Currently loopback-only; no real credential is written.", + ], + de: [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex verwaltet nur llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH lädt diesen Anbieter im laufenden Betrieb neu; Ihr Standardmodell und deepseek-official bleiben unverändert. Derzeit nur über Loopback; es werden keine echten Zugangsdaten geschrieben.", + ], + ja: [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex が管理するのは $DSH_HOME/settings.yaml 内の llm-pi-ai.providers.opencodex だけです。DSH はこのプロバイダーをホットリロードし、既定のモデルと deepseek-official は変更しません。現在はループバック専用で、実際の認証情報は書き込みません。", + ], + ko: [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex는 $DSH_HOME/settings.yaml의 llm-pi-ai.providers.opencodex만 관리합니다. DSH는 이 provider를 hot reload하며 기본 model과 deepseek-official은 변경하지 않습니다. 현재 loopback 전용이며 실제 credential을 기록하지 않습니다.", + ], + ru: [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex управляет только llm-pi-ai.providers.opencodex в $DSH_HOME/settings.yaml. DSH применяет этот провайдер горячей перезагрузкой; модель по умолчанию и deepseek-official остаются без изменений. Сейчас поддерживается только loopback; реальные учётные данные не записываются.", + ], + tr: [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex yalnızca $DSH_HOME/settings.yaml içindeki llm-pi-ai.providers.opencodex bölümünü yönetir. DSH bu sağlayıcıyı çalışırken yeniden yükler; varsayılan modeliniz ve deepseek-official değişmez. Şimdilik yalnızca geri döngü desteklenir; gerçek kimlik bilgisi yazılmaz.", + ], + zh: [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 会热重载该 provider;你的默认模型和 deepseek-official 保持不变。目前仅支持环回地址,且不会写入真实凭据。", + ], + "zh-TW": [ + "DeepSeek Harness (DSH)", + "DeepSeek Harness (DSH)", + "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 會熱重載該 provider;你的預設模型與 deepseek-official 維持不變。目前僅支援 loopback,且不會寫入真實憑證。", + ], +}; + +test("every locale carries the exact DSH label and ownership semantics", async () => { + for (const locale of LOCALES) { + const dict = await readDict(locale); + const expected = DSH_VISIBLE_COPY[locale]; + expect(dict.get("api.clientConfig.clientDsh")).toBe(expected[0]); + expect(dict.get("integrations.tab.dsh")).toBe(expected[1]); + expect(dict.get("integrations.semantics.dsh")).toBe(expected[2]); + } +}); diff --git a/scripts/dsh-rc6-compat-e2e.ts b/scripts/dsh-rc6-compat-e2e.ts new file mode 100644 index 000000000..0f55a4a8c --- /dev/null +++ b/scripts/dsh-rc6-compat-e2e.ts @@ -0,0 +1,329 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; + +export const DSH_RC6_VERSION = "0.1.0-rc.6"; + +const OUTPUT_LIMIT_BYTES = 256 * 1024; +const RUN_TIMEOUT_MS = 60_000; +const TEMPLATE_PLACEHOLDER = "__OCX_BASE_URL__"; +const E2E_MODEL = "dsh-e2e-reasoner"; +const MISSING_MODEL = "dsh-e2e-missing"; +const CREDENTIAL_ENV_PATTERN = /(?:_API_KEY|_API_TOKEN|_AUTH_TOKEN|_ACCESS_TOKEN|_SECRET_ACCESS_KEY)$/i; + +type StringEnv = Record; + +interface ChildDirs { + home: string; + dshHome: string; +} + +interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface UpstreamRequest { + model?: unknown; + reasoning?: { effort?: unknown }; + tools?: Array<{ name?: unknown; function?: { name?: unknown } }>; + input?: unknown; +} + +export function assertExpectedDshVersion(stdout: string): void { + const actual = stdout.trim(); + if (actual !== DSH_RC6_VERSION) { + throw new Error(`expected DSH ${DSH_RC6_VERSION}, received ${actual || "no version"}`); + } +} + +export function renderDshSettings(template: string, openCodexBaseUrl: string): string { + let parsed: URL; + try { + parsed = new URL(openCodexBaseUrl); + } catch { + throw new Error("DSH compatibility E2E requires a valid loopback OpenCodex URL"); + } + if (parsed.protocol !== "http:" || !["127.0.0.1", "localhost", "::1", "[::1]"].includes(parsed.hostname)) { + throw new Error("DSH compatibility E2E requires a loopback OpenCodex URL"); + } + if (template.split(TEMPLATE_PLACEHOLDER).length !== 2) { + throw new Error(`DSH settings template must contain exactly one ${TEMPLATE_PLACEHOLDER} placeholder`); + } + return template.replace(TEMPLATE_PLACEHOLDER, openCodexBaseUrl.replace(/\/+$/, "")); +} + +export function buildDshChildEnv(base: StringEnv, dirs: ChildDirs): StringEnv { + const env: StringEnv = { ...base }; + for (const key of Object.keys(env)) { + if (/^(?:HTTP|HTTPS|ALL)_PROXY$/i.test(key) || CREDENTIAL_ENV_PATTERN.test(key)) { + delete env[key]; + } + } + env.HOME = dirs.home; + env.DSH_HOME = dirs.dshHome; + env.DSH_TELEMETRY_DISABLED = "1"; + env.NO_PROXY = "127.0.0.1,localhost,::1"; + return env; +} + +async function readLimited(stream: ReadableStream, maxBytes: number): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > maxBytes) throw new Error("child process output exceeded the compatibility E2E limit"); + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(combined); +} + +async function runCommand( + executable: string, + args: string[], + options: { cwd: string; env: StringEnv; timeoutMs: number }, +): Promise { + let child: ReturnType; + try { + child = Bun.spawn([executable, ...args], { + cwd: options.cwd, + env: options.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + } catch { + throw new Error("failed to execute the binary selected by DSH_RC6_BIN"); + } + + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill(); + }, options.timeoutMs); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + readLimited(child.stdout, OUTPUT_LIMIT_BYTES), + readLimited(child.stderr, OUTPUT_LIMIT_BYTES), + ]); + if (timedOut) throw new Error(`DSH compatibility process exceeded ${options.timeoutMs}ms`); + return { exitCode, stdout, stderr }; + } catch (error) { + child.kill(); + await child.exited.catch(() => undefined); + throw error; + } finally { + clearTimeout(timeout); + } +} + +function sse(events: Array>): Response { + const body = events.map(event => `data: ${JSON.stringify(event)}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function usage() { + return { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 4, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 14, + }; +} + +function toolResponse(): Response { + const item = { + type: "function_call", + id: "fc_dsh_e2e", + call_id: "call_dsh_e2e", + name: "read", + arguments: JSON.stringify({ file_path: "probe.txt" }), + status: "completed", + }; + return sse([ + { type: "response.created", response: { id: "resp_dsh_e2e_tool", status: "in_progress", output: [] } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "", status: "in_progress" } }, + { type: "response.function_call_arguments.delta", output_index: 0, item_id: item.id, delta: item.arguments }, + { type: "response.function_call_arguments.done", output_index: 0, item_id: item.id, arguments: item.arguments }, + { type: "response.output_item.done", output_index: 0, item }, + { + type: "response.completed", + response: { id: "resp_dsh_e2e_tool", status: "completed", output: [item], usage: usage() }, + }, + ]); +} + +function textResponse(text: string, suffix: string): Response { + const item = { + type: "message", + id: `msg_dsh_e2e_${suffix}`, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }; + return sse([ + { type: "response.created", response: { id: `resp_dsh_e2e_${suffix}`, status: "in_progress", output: [] } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, status: "in_progress", content: [] } }, + { type: "response.output_text.delta", item_id: item.id, output_index: 0, content_index: 0, delta: text }, + { type: "response.output_item.done", output_index: 0, item }, + { + type: "response.completed", + response: { id: `resp_dsh_e2e_${suffix}`, status: "completed", output: [item], usage: usage() }, + }, + ]); +} + +function finalResponse(): Response { + return textResponse("DSH_E2E_OK", "final"); +} + +function hasReadTool(request: UpstreamRequest): boolean { + return (request.tools ?? []).some(tool => tool.name === "read" || tool.function?.name === "read"); +} + +function opencodexConfig(baseUrl: string): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "dsh-e2e", + providers: { + "dsh-e2e": { + adapter: "openai-responses", + baseUrl, + authMode: "key", + apiKey: "test-key", + allowPrivateNetwork: true, + liveModels: false, + models: [E2E_MODEL], + }, + }, + } as OcxConfig; +} + +function settingsWithDefaultModel(settings: string, model: string): string { + const needle = ` model: ${E2E_MODEL}`; + if (settings.split(needle).length !== 2) throw new Error("DSH E2E fixture has an ambiguous default model"); + return settings.replace(needle, ` model: ${model}`); +} + +async function runCompatibilityE2E(): Promise { + const dshBin = process.env.DSH_RC6_BIN?.trim(); + if (!dshBin) throw new Error("set DSH_RC6_BIN to the DSH 0.1.0-rc.6 executable"); + + const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const template = readFileSync(join(repoRoot, "tests", "fixtures", "dsh-rc6-compat-e2e-settings.yaml"), "utf8"); + const root = mkdtempSync(join(tmpdir(), "ocx-dsh-rc6-e2e-")); + const ocxHome = join(root, "ocx-home"); + const childHome = join(root, "home"); + const dshHome = join(root, "dsh-home"); + const workspace = join(root, "workspace"); + for (const dir of [ocxHome, childHome, dshHome, workspace]) mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(join(workspace, "probe.txt"), "TOOL_OK\n", { mode: 0o600 }); + + const previousOpenCodexHome = process.env.OPENCODEX_HOME; + const requests: UpstreamRequest[] = []; + let upstream: ReturnType | undefined; + let proxy: ReturnType | undefined; + try { + const childEnv = buildDshChildEnv(process.env, { home: childHome, dshHome }); + const version = await runCommand(dshBin, ["--version"], { cwd: workspace, env: childEnv, timeoutMs: 10_000 }); + if (version.exitCode !== 0) throw new Error("DSH version probe failed"); + assertExpectedDshVersion(version.stdout); + + upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + if (new URL(req.url).pathname !== "/v1/responses" || req.method !== "POST") { + return new Response("not found", { status: 404 }); + } + const request = await req.json() as UpstreamRequest; + requests.push(request); + const hasToolOutput = JSON.stringify(request.input).includes("TOOL_OK"); + if (requests.length === 1) { + if (request.model !== E2E_MODEL) return new Response("unexpected model", { status: 400 }); + if (request.reasoning?.effort !== "high") return new Response("unexpected reasoning effort", { status: 400 }); + if (!hasReadTool(request)) return new Response("read tool missing", { status: 400 }); + return toolResponse(); + } + if (hasToolOutput && hasReadTool(request)) { + return finalResponse(); + } + // rc.6 performs one no-tools title request alongside the two Agent data steps. + if (!hasToolOutput && !hasReadTool(request)) return textResponse("DSH E2E", "title"); + return new Response("unexpected DSH request shape", { status: 400 }); + }, + }); + + process.env.OPENCODEX_HOME = ocxHome; + saveConfig(opencodexConfig(new URL("/v1", upstream.url).toString())); + proxy = startServer(0); + const settings = renderDshSettings(template, new URL("/v1", proxy.url).toString()); + writeFileSync(join(dshHome, "settings.yaml"), settings, { mode: 0o600 }); + + const success = await runCommand( + dshBin, + ["--profile", "headless", "Read probe.txt with the read tool, then report the result."], + { cwd: workspace, env: childEnv, timeoutMs: RUN_TIMEOUT_MS }, + ); + if (success.exitCode !== 0) throw new Error("DSH success scenario exited non-zero"); + if (!success.stdout.includes("DSH_E2E_OK")) throw new Error("DSH success scenario did not return DSH_E2E_OK"); + const agentRequests = requests.filter(hasReadTool); + if (agentRequests.length !== 2) { + throw new Error(`DSH success scenario made ${agentRequests.length} Agent requests instead of 2`); + } + if (requests.length !== 3) throw new Error(`DSH rc.6 made ${requests.length} total requests instead of 3`); + + writeFileSync(join(dshHome, "settings.yaml"), settingsWithDefaultModel(settings, MISSING_MODEL), { mode: 0o600 }); + const beforeUnknown = requests.length; + const unknown = await runCommand( + dshBin, + ["--profile", "headless", "This request must fail before network dispatch."], + { cwd: workspace, env: childEnv, timeoutMs: RUN_TIMEOUT_MS }, + ); + if (unknown.exitCode === 0) throw new Error("DSH unknown-model scenario unexpectedly succeeded"); + if (!`${unknown.stdout}\n${unknown.stderr}`.includes("UNKNOWN_MODEL")) { + throw new Error("DSH unknown-model scenario did not report UNKNOWN_MODEL"); + } + if (requests.length !== beforeUnknown) throw new Error("DSH unknown-model scenario reached the upstream server"); + + console.log(`DSH ${DSH_RC6_VERSION} compatibility E2E passed: tool continuation and unknown-model refusal`); + } finally { + if (proxy) await proxy.stop(true); + upstream?.stop(true); + if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpenCodexHome; + rmSync(root, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + await runCompatibilityE2E().catch((error) => { + const message = error instanceof Error ? error.message : "unknown failure"; + console.error(`[dsh-rc6-compat-e2e] ${message}`); + process.exitCode = 1; + }); +} diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 96efbc4c2..f8773e685 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -62,7 +62,11 @@ export interface ExportCommandDeps extends RuntimeApiDeps { * `/api/models` row plus the modality list Pi consumes. The launcher's row type predates * the Pi exporter and stops at the fields OpenCode needs. */ -type ExportProxyModelRow = OpencodeProxyModelRow & { inputModalities?: string[] }; +type ExportProxyModelRow = OpencodeProxyModelRow & { + inputModalities?: string[]; + reasoningEfforts?: string[]; + defaultReasoningEffort?: string; +}; /** Same authoritativeness rule the serializers apply, for the degraded-count line. */ function hasContextLimit(model: ExportModel): boolean { @@ -83,12 +87,21 @@ export function exportModelsFromProxyRows( rows: readonly ExportProxyModelRow[], config: OcxConfig, ): ExportModel[] { - const modalities = new Map(); + const metadata = new Map>(); for (const row of rows) { const namespaced = row.namespaced?.trim(); - if (namespaced && Array.isArray(row.inputModalities) && row.inputModalities.length > 0) { - if (!modalities.has(namespaced)) modalities.set(namespaced, [...row.inputModalities]); - } + if (!namespaced || metadata.has(namespaced)) continue; + metadata.set(namespaced, { + ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0 + ? { inputModalities: [...row.inputModalities] } + : {}), + ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0 + ? { reasoningEfforts: [...row.reasoningEfforts] } + : {}), + ...(typeof row.defaultReasoningEffort === "string" && row.defaultReasoningEffort.length > 0 + ? { defaultReasoningEffort: row.defaultReasoningEffort } + : {}), + }); } return opencodeCatalogFromProxyRows(rows, config).map(entry => { const model: ExportModel = { @@ -99,8 +112,7 @@ export function exportModelsFromProxyRows( if (entry.native) model.native = true; if (entry.displayName) model.displayName = entry.displayName; if (entry.contextWindow !== undefined) model.contextWindow = entry.contextWindow; - const input = modalities.get(entry.namespaced); - if (input) model.inputModalities = input; + Object.assign(model, metadata.get(entry.namespaced)); return model; }); } diff --git a/src/cli/help.ts b/src/cli/help.ts index cee9ea6c8..f729c1d81 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -58,7 +58,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (7 clients) + ocx export --client Print a client config wired to the running proxy (8 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, and updates diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 1572cfddb..3437b0b7d 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -216,8 +216,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (opencode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index c338aece9..44240c9ef 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -21,9 +21,10 @@ */ import { homedir } from "node:os"; import { existsSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; +import { providerCodexAccountMode } from "../providers/registry"; import { probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -376,6 +377,28 @@ export function gajaeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(gajaeHomeDir(env, home), "agent", "models.yml"); } +/** DSH_HOME uses the raw nonblank value; trimming it would name a different path. */ +export function dshHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const raw = env.DSH_HOME; + if (raw === undefined || raw.trim().length === 0) return join(home, ".dsh"); + if (raw === "~") return home; + if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(home, raw.slice(2)); + if (!isAbsolute(raw)) { + throw new ClientPathError( + `DSH_HOME must be an absolute path or start with ~; "${raw}" depends on the working directory, ` + + "so opencodex and DSH would disagree about which settings file it names.", + ); + } + // DSH calls node:path.resolve after tilde expansion. Preserve the raw value + // for the decision above, then normalize the absolute spelling the same way + // so both processes bind ownership and locks to one path string. + return resolve(raw); +} + +export function dshConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(dshHomeDir(env, home), "settings.yaml"); +} + /** * One proxy-routed model destined for a client config. Deliberately narrower than * `CatalogModel` so a serializer cannot reach for a field that does not survive the @@ -414,7 +437,8 @@ export type ExportClientId = | "hermes" | "openclaw" | "kimi" - | "gajae"; + | "gajae" + | "dsh"; export interface ExportClientSpec { id: ExportClientId; @@ -463,7 +487,8 @@ export interface ExportClientSpec { */ function authoritativeContextWindow(contextWindow: number | undefined): number | undefined { if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { - return Math.floor(contextWindow); + const integer = Math.floor(contextWindow); + return integer > 0 ? integer : undefined; } return undefined; } @@ -522,6 +547,18 @@ function inputModalitiesForClient( return kept.length > 0 ? kept : null; } +/** DSH rc.6 accepts text/image; unknown values degrade to text, while audio-only cannot be represented. */ +function dshInputModalities(modalities: readonly string[] | undefined): string[] | null { + const declared = modalities ?? []; + if (declared.length === 0) return ["text"]; + const kept: string[] = []; + for (const value of declared) { + if ((value === "text" || value === "image") && !kept.includes(value)) kept.push(value); + } + if (kept.length > 0) return kept; + return declared.every(value => value === "audio") ? null : ["text"]; +} + /** * Label shared by every client: `" ()"`. The * provider suffix is what makes two same-named models from different upstreams @@ -770,6 +807,31 @@ export interface GajaeGeneratedConfig { providers: Record; } +export type DshReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type DshWireReasoningEffort = DshReasoningEffort | "ultra"; + +export interface DshModelEntry { + id: string; + name: string; + input: string[]; + contextWindow?: number; + reasoningEfforts?: Partial>; +} + +export interface DshProviderBlock { + displayName: "OpenCodex"; + api: "openai-responses"; + baseURL: string; + headers: { Authorization: "Bearer ocx_data_dsh" }; + models: DshModelEntry[]; +} + +export interface DshGeneratedConfig { + "llm-pi-ai": { + providers: Record; + }; +} + /** * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`), * unlike OpenCode's keyed object. @@ -975,6 +1037,84 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig { }; } +const DSH_EFFORT_ORDER: readonly DshReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + +function dshReasoningEfforts(model: ExportModel): DshModelEntry["reasoningEfforts"] { + const offered = new Set(); + for (const raw of model.reasoningEfforts ?? []) { + const effort = raw.trim().toLowerCase(); + if (effort === "ultra" || DSH_EFFORT_ORDER.includes(effort as DshReasoningEffort)) offered.add(effort); + } + if (offered.size === 0) return undefined; + const entries: Array<[DshReasoningEffort, DshWireReasoningEffort]> = []; + for (const effort of DSH_EFFORT_ORDER) { + if (effort !== "max") { + if (offered.has(effort)) entries.push([effort, effort]); + continue; + } + // DSH's key is the selectable level; the value is what it sends on the + // wire. Preserve OpenCodex's `ultra` spelling when that is the only + // highest effort, exactly like the rc.6 `max: ultra` contract. + if (offered.has("max")) entries.push(["max", "max"]); + else if (offered.has("ultra")) entries.push(["max", "ultra"]); + } + return Object.fromEntries(entries); +} + +function isKnownSafeDshCombo(model: ExportModel, config: OcxConfig): boolean { + const combos = (config as { combos?: unknown }).combos; + if (typeof combos !== "object" || combos === null || Array.isArray(combos)) return false; + const combo = (combos as Record)[model.id]; + if (typeof combo !== "object" || combo === null || Array.isArray(combo)) return false; + const targets = (combo as { targets?: unknown }).targets; + if (!Array.isArray(targets) || targets.length === 0) return false; + return targets.every(target => { + if (typeof target !== "object" || target === null || Array.isArray(target)) return false; + const provider = (target as { provider?: unknown }).provider; + const modelId = (target as { model?: unknown }).model; + return typeof provider === "string" + && provider.length > 0 + && provider === provider.trim() + && provider !== "openai" + && typeof modelId === "string" + && modelId.length > 0 + && modelId === modelId.trim(); + }); +} + +function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig { + const direct = providerCodexAccountMode("openai", ctx.config?.providers?.openai) === "direct"; + const models: DshModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + if (direct && (model.native === true || model.provider === "openai")) continue; + if (direct && model.provider === "combo" && (!ctx.config || !isKnownSafeDshCombo(model, ctx.config))) continue; + const input = dshInputModalities(model.inputModalities); + if (input === null) continue; + const contextWindow = authoritativeContextWindow(model.contextWindow); + const reasoningEfforts = dshReasoningEfforts(model); + models.push({ + id: model.namespaced, + name: exportModelLabel(model), + input, + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(reasoningEfforts ? { reasoningEfforts } : {}), + }); + } + return { + "llm-pi-ai": { + providers: { + [OPENCODE_PROVIDER_ID]: { + displayName: "OpenCodex", + api: "openai-responses", + baseURL: ctx.baseUrl, + headers: { Authorization: "Bearer ocx_data_dsh" }, + models, + }, + }, + }, + }; +} + /** * Per-client model counts, read back off the SERIALIZED document rather than * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about @@ -1019,6 +1159,11 @@ function summarizeGajae(document: unknown): { modelCount: number; modelsWithoutL return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } +function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as DshGeneratedConfig | undefined)?.["llm-pi-ai"]?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + /** One fragment at `path`, built from this client's own document. */ function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution { return { clientId, fragments: [{ path, value }] }; @@ -1070,6 +1215,11 @@ function buildGajaeContribution(ctx: ExportContext): ManagedContribution { return singleFragment("gajae", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } +function buildDshContribution(ctx: ExportContext): ManagedContribution { + const doc = buildDshClientConfig(ctx); + return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1167,6 +1317,18 @@ export const EXPORT_CLIENTS: Record = { // strict schema with no header field, so the dedicated header has nowhere to go loopbackOnly: true, }, + dsh: { + id: "dsh", + filename: "settings.yaml", + destination: env => dshConfigPath(env), + apiKeyEnv: "", + exportHint: "DSH uses a non-secret loopback bearer placeholder in settings.yaml; loopback needs no key.", + build: buildDshClientConfig, + format: "yaml", + summarize: summarizeDsh, + buildContribution: buildDshContribution, + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/integrations/omp-yaml-source.ts b/src/integrations/omp-yaml-source.ts index 2a0dc3839..dc2c19275 100644 --- a/src/integrations/omp-yaml-source.ts +++ b/src/integrations/omp-yaml-source.ts @@ -1,14 +1,11 @@ /** - * Source-preserving mutation for the one YAML fragment managed by OMP. + * Source-preserving mutation for one plain block-map YAML fragment. * - * The general integration writer operates on parsed documents. Re-rendering a - * shared YAML file would preserve values but destroy comments and formatting - * outside `providers.opencodex`. OMP is the only client whose ownership model - * deliberately permits those unrelated source edits, so its writer replaces - * or removes only the exact block-style mapping entry it owns. - * - * Unsupported or ambiguous YAML returns `null`. The caller treats that as an - * unsafe refusal instead of falling back to whole-document serialization. + * Shared client settings are not ours to re-render. This scanner accepts only + * the small, unambiguous source shape we can patch byte-for-byte around the + * owned leaf. Every candidate is parsed again and compared with the complete + * expected document; unsupported YAML fails closed and never falls back to a + * whole-document serializer. */ import { renderYaml } from "./serialize"; @@ -18,6 +15,26 @@ interface SourceLine { body: string; } +interface LocatedEntry { + lines: readonly SourceLine[]; + index: number; + indent: number; + endIndex: number; +} + +interface MissingEntry { + lines: readonly SourceLine[]; + missingDepth: number; + indent: number; + insertAt: number; +} + +type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry }; + +export type YamlFragmentMutation = + | { kind: "upsert"; value: unknown } + | { kind: "remove"; createdContainers: readonly string[] }; + export type OmpYamlMutation = | { kind: "upsert"; value: unknown } | { kind: "remove"; removeEmptyProviders: boolean }; @@ -54,11 +71,15 @@ function hasInlineComment(line: string): boolean { return line.includes("#"); } +function regexpEscape(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + function isPlainBlockKey(line: string, indent: number, key: string): boolean { const spaces = leadingSpaces(line); if (spaces !== indent) return false; const rest = line.slice(indent); - return new RegExp(`^${key}:[ ]*(?:#.*)?$`, "u").test(rest); + return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest); } function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null { @@ -86,9 +107,8 @@ function childEnd( const body = lines[index]!.body; const spaces = leadingSpaces(body); if (spaces === null) return null; - // Blank lines and same-level comments are conservatively outside the - // managed entry. Deeper comments would be destroyed by replacement, so - // refuse rather than guessing whether the user meant to keep them. + // Blank lines and same-level comments remain outside our replacement. + // Deeper comments belong to the leaf and would be destroyed, so refuse. if (isBlank(body)) return index; if (isComment(body)) return spaces <= indent ? index : null; if (spaces <= indent) return index; @@ -112,8 +132,7 @@ function canonicalValue(value: unknown): unknown { function semanticallyMatches(text: string, expected: unknown): boolean { try { const parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text); - return JSON.stringify(canonicalValue(parsed)) - === JSON.stringify(canonicalValue(expected)); + return JSON.stringify(canonicalValue(parsed)) === JSON.stringify(canonicalValue(expected)); } catch { return false; } @@ -123,108 +142,217 @@ function lineEnding(text: string): "\n" | "\r\n" { return text.includes("\r\n") ? "\r\n" : "\n"; } -function renderedEntry(value: unknown, indent: number, eol: "\n" | "\r\n"): string { - return renderYaml({ opencodex: value }, indent).replaceAll("\n", eol); -} - -/** - * Patch `providers.opencodex` while preserving every byte outside that entry. - * The returned text is parsed and compared with `expected` before it is - * accepted, so a scanner mistake fails closed rather than writing bad YAML. - */ -export function patchOmpYamlSource( - text: string, - mutation: OmpYamlMutation, - expected: unknown, -): string | null { - const lines = sourceLines(text); - const eol = lineEnding(text); - const providerIndexes = lines - .map((line, index) => isPlainBlockKey(line.body, 0, "providers") ? index : -1) - .filter(index => index >= 0); - - if (providerIndexes.length === 0) { - if (mutation.kind === "remove") return null; - const separator = text.length === 0 || text.endsWith("\n") ? "" : eol; - const patched = `${text}${separator}providers:${eol}${renderedEntry(mutation.value, 2, eol)}`; - return semanticallyMatches(patched, expected) ? patched : null; +function readPath(doc: unknown, path: readonly string[]): unknown { + let cursor = doc; + for (const key of path) { + if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) return undefined; + cursor = (cursor as Record)[key]; + if (cursor === undefined) return undefined; } - if (providerIndexes.length !== 1) return null; + return cursor; +} - const providersIndex = providerIndexes[0]!; - const providersEnd = containerEnd(lines, providersIndex, 0); - if (providersEnd === null) return null; +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} - let inferredIndent: number | null = null; - for (let index = providersIndex + 1; index < providersEnd; index += 1) { +function immediateIndent( + lines: readonly SourceLine[], + start: number, + end: number, + parentIndent: number, +): number | null { + let indent: number | null = null; + for (let index = start; index < end; index += 1) { const body = lines[index]!.body; const spaces = leadingSpaces(body); if (spaces === null) return null; - if (!isBlank(body) && !isComment(body) && spaces > 0) { - inferredIndent = inferredIndent === null ? spaces : Math.min(inferredIndent, spaces); + if (isBlank(body) || isComment(body) || spaces <= parentIndent) continue; + indent = indent === null ? spaces : Math.min(indent, spaces); + } + return indent ?? parentIndent + 2; +} + +/** Locate a path only when every present segment is a plain block-map key. */ +function locatePath(text: string, parsed: unknown, path: readonly string[]): LocatedPath | null { + if (path.length === 0 || !isPlainRecord(parsed)) return null; + const lines = sourceLines(text); + if (lines.some(line => leadingSpaces(line.body) === null)) return null; + + let rangeStart = 0; + let rangeEnd = lines.length; + let parentIndent = -2; + const prefix: string[] = []; + for (let depth = 0; depth < path.length; depth += 1) { + const indent = depth === 0 ? 0 : immediateIndent(lines, rangeStart, rangeEnd, parentIndent); + if (indent === null) return null; + const matches: number[] = []; + for (let index = rangeStart; index < rangeEnd; index += 1) { + if (isPlainBlockKey(lines[index]!.body, indent, path[depth]!)) matches.push(index); + } + if (matches.length > 1) return null; + prefix.push(path[depth]!); + if (matches.length === 0) { + // The parser saw this key through syntax we do not patch (quoted/flow, + // merge aliases, or an ambiguous indentation shape). + if (readPath(parsed, prefix) !== undefined) return null; + const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length; + return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } }; } + + const index = matches[0]!; + const end = containerEnd(lines, index, indent); + if (end === null) return null; + if (depth === path.length - 1) { + if (hasInlineComment(lines[index]!.body)) return null; + const leafEnd = childEnd(lines, index, end, indent); + if (leafEnd === null) return null; + return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } }; + } + if (!isPlainRecord(readPath(parsed, prefix))) return null; + rangeStart = index + 1; + rangeEnd = end; + parentIndent = indent; + } + return null; +} + +function nestedValue(path: readonly string[], value: unknown): Record { + let nested: unknown = value; + for (let index = path.length - 1; index >= 0; index -= 1) nested = { [path[index]!]: nested }; + return nested as Record; +} + +function rendered(value: unknown, indent: number, eol: "\n" | "\r\n"): string { + return renderYaml(value as Record, indent).replaceAll("\n", eol); +} + +function preserveFinalNewline(candidate: string, original: string, eol: "\n" | "\r\n"): string { + if (original.length > 0 && !original.endsWith("\n") && candidate.endsWith(eol)) { + return candidate.slice(0, -eol.length); } - const childIndexes = inferredIndent === null - ? [] - : lines.slice(providersIndex + 1, providersEnd) - .map((line, offset) => ( - isPlainBlockKey(line.body, inferredIndent!, "opencodex") - ? providersIndex + 1 + offset - : -1 - )) - .filter(index => index >= 0); - if (childIndexes.length > 1) return null; - - const childIndex = childIndexes[0]; - if (childIndex === undefined) { - if (mutation.kind === "remove") return null; - const indent = inferredIndent ?? 2; - const insertAt = providersEnd < lines.length ? lines[providersEnd]!.start : text.length; - const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : ""; - const patched = `${text.slice(0, insertAt)}${prefix}${renderedEntry(mutation.value, indent, eol)}${text.slice(insertAt)}`; - return semanticallyMatches(patched, expected) ? patched : null; + return candidate; +} + +function upsertSource( + text: string, + parsed: unknown, + path: readonly string[], + value: unknown, +): string | null { + const located = locatePath(text, parsed, path); + if (located === null) return null; + const eol = lineEnding(text); + if (located.kind === "existing") { + const { lines, index, indent, endIndex } = located.entry; + const startOffset = lines[index]!.start; + const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length; + const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`; + return preserveFinalNewline(candidate, text, eol); } - const childIndent = leadingSpaces(lines[childIndex]!.body); - if (childIndent === null || childIndent <= 0) return null; - if (hasInlineComment(lines[childIndex]!.body)) return null; - const endIndex = childEnd(lines, childIndex, providersEnd, childIndent); - if (endIndex === null) return null; - const startOffset = lines[childIndex]!.start; + const { missingDepth, indent, insertAt } = located.entry; + const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : ""; + const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol); + const candidate = `${text.slice(0, insertAt)}${prefix}${insertion}${text.slice(insertAt)}`; + return preserveFinalNewline(candidate, text, eol); +} + +function removeExactPath(text: string, path: readonly string[], requireEmpty: boolean): string | null { + let parsed: unknown; + try { + parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text); + } catch { + return null; + } + const located = locatePath(text, parsed, path); + if (located === null || located.kind !== "existing") return null; + const { lines, index, endIndex } = located.entry; + const startOffset = lines[index]!.start; const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length; + if (requireEmpty) { + const sourceBody = text.slice(lines[index]!.end, endOffset); + if (sourceBody.trim().length > 0) return null; + } + return `${text.slice(0, startOffset)}${text.slice(endOffset)}`; +} - if (mutation.kind === "upsert") { - const patched = `${text.slice(0, startOffset)}${renderedEntry(mutation.value, childIndent, eol)}${text.slice(endOffset)}`; - return semanticallyMatches(patched, expected) ? patched : null; +function planSourceRemoval( + text: string, + path: readonly string[], + createdContainers: readonly string[], +): { text: string; prunedContainers: string[] } | null { + let next = removeExactPath(text, path, false); + if (next === null) return null; + const created = new Set(createdContainers); + const prunedContainers: string[] = []; + for (let depth = path.length - 1; depth >= 1; depth -= 1) { + const containerPath = path.slice(0, depth); + const encoded = containerPath.join("\u0000"); + if (!created.has(encoded)) continue; + const pruned = removeExactPath(next, containerPath, true); + // A user-added sibling/comment makes this ancestor source-owned now. Keep + // it (and every parent) while still removing our leaf. + if (pruned === null) break; + next = pruned; + prunedContainers.push(encoded); } + return { text: next, prunedContainers }; +} - let patched = `${text.slice(0, startOffset)}${text.slice(endOffset)}`; - if (mutation.removeEmptyProviders) { - let remaining: { providers?: unknown } | null; - try { - remaining = Bun.YAML.parse(patched) as { providers?: unknown } | null; - } catch { - return null; - } - if (remaining && Object.hasOwn(remaining, "providers")) { - const provider = remaining.providers; - const empty = provider === null || ( - provider && typeof provider === "object" && !Array.isArray(provider) - && Object.keys(provider as Record).length === 0 - ); - if (!empty) return semanticallyMatches(patched, expected) ? patched : null; - if (hasInlineComment(lines[providersIndex]!.body)) return null; - - const providerStart = lines[providersIndex]!.start; - // Removing a container we created is safe only when nothing but our - // entry occupied its source range. Comments or blank formatting make - // that range user-owned and therefore ambiguous. - for (let index = providersIndex + 1; index < providersEnd; index += 1) { - if (index >= childIndex && index < endIndex) continue; - if (lines[index]!.body.length > 0) return null; - } - patched = `${text.slice(0, providerStart)}${text.slice(endOffset)}`; - } +/** Containers whose source ranges are still empty and safe to prune. */ +export function sourcePrunableYamlContainers( + text: string, + path: readonly string[], + createdContainers: readonly string[], +): readonly string[] | null { + return planSourceRemoval(text, path, createdContainers)?.prunedContainers ?? null; +} + +function removeSource( + text: string, + path: readonly string[], + createdContainers: readonly string[], +): string | null { + return planSourceRemoval(text, path, createdContainers)?.text ?? null; +} + +/** + * Patch one arbitrary plain block-map path, preserving every other byte. + */ +export function patchYamlFragmentSource( + text: string, + path: readonly string[], + mutation: YamlFragmentMutation, + expected: unknown, +): string | null { + let parsed: unknown; + try { + parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text); + } catch { + return null; } - return semanticallyMatches(patched, expected) ? patched : null; + const patched = mutation.kind === "upsert" + ? upsertSource(text, parsed, path, mutation.value) + : removeSource(text, path, mutation.createdContainers); + return patched !== null && semanticallyMatches(patched, expected) ? patched : null; +} + +/** Backward-compatible OMP wrapper around the generic path patcher. */ +export function patchOmpYamlSource( + text: string, + mutation: OmpYamlMutation, + expected: unknown, +): string | null { + return patchYamlFragmentSource( + text, + ["providers", "opencodex"], + mutation.kind === "upsert" + ? mutation + : { + kind: "remove", + createdContainers: mutation.removeEmptyProviders ? ["providers"] : [], + }, + expected, + ); } diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 889909a5a..26402cd2b 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -12,6 +12,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { EXPORT_CLIENTS, + dshConfigPath, + dshHomeDir, gajaeConfigPath, gajaeHomeDir, hermesConfigPath, @@ -38,6 +40,10 @@ export interface IntegrationClientSpec { configPath: (env?: NodeJS.ProcessEnv, home?: string) => string; /** Directory whose existence is the cheap "is it installed?" signal. */ detectDir: (env?: NodeJS.ProcessEnv, home?: string) => string; + /** Patch only this block-map YAML leaf; never re-render the shared file. */ + sourcePreservingYaml?: { path: readonly string[] }; + /** Coordinate the complete mutation through a sibling config lock. */ + writerLock?: { suffix: ".lock" }; } /** @@ -74,6 +80,7 @@ export const INTEGRATION_CLIENTS: Record ompModelsConfigPath(env, home), detectDir: (env = process.env, home = homedir()) => ompAgentDir(env, home), + sourcePreservingYaml: { path: ["providers", "opencodex"] }, }, hermes: { id: "hermes", @@ -98,6 +105,13 @@ export const INTEGRATION_CLIENTS: Record gajaeConfigPath(env, home), detectDir: (env = process.env, home = homedir()) => gajaeHomeDir(env, home), }, + dsh: { + id: "dsh", + configPath: (env = process.env, home = homedir()) => dshConfigPath(env, home), + detectDir: (env = process.env, home = homedir()) => dshHomeDir(env, home), + sourcePreservingYaml: { path: ["llm-pi-ai", "providers", "opencodex"] }, + writerLock: { suffix: ".lock" }, + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 443d093f1..e0fef0483 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -136,8 +136,8 @@ function recordedFragmentFingerprint( * what we may rewrite, and the contribution hash proves our catalog has not * moved on. Three classes of client (revising the unconditional whole-file * rule of devlog 260802_client_toggle_api/021 §3 for json — #1631): - * OMP is fragment-scoped because its writer patches only - * `providers.opencodex`, so the whole-file check is skipped entirely; + * registry-declared source-preserving YAML clients are fragment-scoped because + * their writers patch only the owned leaf, so the whole-file check is skipped; * strict-json clients keep the whole-file check but downgrade a drift with * intact owned fragments to `stale`, because a rewrite there can lose only * formatting (comments cannot parse, non-round-tripping numbers are refused @@ -194,7 +194,8 @@ export function classifyIntegration(input: { if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) { return { state: "conflict", reason: "foreign-edit" }; } - if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) { + if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml + && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) { /* * The file changed since we wrote it, but every fragment we own is still * byte-for-byte what we put there — a sibling edit, not tampering. Apply diff --git a/src/integrations/writer-lock.ts b/src/integrations/writer-lock.ts new file mode 100644 index 000000000..adf2e4091 --- /dev/null +++ b/src/integrations/writer-lock.ts @@ -0,0 +1,88 @@ +import { rm, writeFile } from "node:fs/promises"; + +const LOCK_DEADLINE_MS = 2_000; +const LOCK_DELAYS_MS = [20, 40, 80, 160, 200] as const; + +export interface IntegrationWriterLockSeams { + writeFile: ( + path: string, + payload: string, + options: { flag: "wx"; mode: 0o600 }, + ) => Promise; + removeFile: (path: string) => Promise; + now: () => number; + delay: (milliseconds: number) => Promise; + pid: number; +} + +export class IntegrationWriterLockBusyError extends Error { + constructor(readonly lockPath: string) { + super("integration_mutation_busy"); + this.name = "IntegrationWriterLockBusyError"; + } +} + +export class IntegrationWriterLockIOError extends Error { + constructor(readonly lockPath: string, readonly operation: "acquire" | "release", cause: unknown) { + // The management route returns Error.message to its caller. Keep the + // private config path and OS diagnostic on typed fields/cause, not the wire. + super(`integration writer lock ${operation} failed`, { cause }); + this.name = "IntegrationWriterLockIOError"; + } +} + +const defaultSeams: IntegrationWriterLockSeams = { + writeFile: async (path, payload, options) => { await writeFile(path, payload, options); }, + removeFile: async path => { await rm(path); }, + now: () => Date.now(), + delay: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), + pid: process.pid, +}; + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} + +/** + * Hold the exact sibling `.lock` around one complete transaction. + * A contender is never deleted: release runs only after our exclusive create + * succeeded. + */ +export async function withIntegrationWriterLock( + configPath: string, + operation: () => Promise, + seams: IntegrationWriterLockSeams = defaultSeams, + suffix: ".lock" = ".lock", +): Promise { + const lockPath = `${configPath}${suffix}`; + const startedAt = seams.now(); + let delayIndex = 0; + for (;;) { + try { + await seams.writeFile(lockPath, `${seams.pid}\n`, { flag: "wx", mode: 0o600 }); + break; + } catch (error) { + if (errorCode(error) !== "EEXIST") { + throw new IntegrationWriterLockIOError(lockPath, "acquire", error); + } + if (seams.now() - startedAt >= LOCK_DEADLINE_MS) { + throw new IntegrationWriterLockBusyError(lockPath); + } + const delayMs = LOCK_DELAYS_MS[Math.min(delayIndex, LOCK_DELAYS_MS.length - 1)]!; + delayIndex += 1; + await seams.delay(delayMs); + } + } + + try { + return await operation(); + } finally { + try { + await seams.removeFile(lockPath); + } catch (error) { + throw new IntegrationWriterLockIOError(lockPath, "release", error); + } + } +} diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 4c6abd6e9..1789ef6f4 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -9,6 +9,7 @@ * * Design of record: devlog/_fin/260802_client_toggle_api/030 and 031. */ +import { homedir } from "node:os"; import { dirname } from "node:path"; import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; import { isLoopbackHostname } from "../codex/inject"; @@ -23,7 +24,8 @@ import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; import { matchesOperationResult, newOpId, type JournalEntry } from "./journal"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; -import { patchOmpYamlSource } from "./omp-yaml-source"; +import { patchYamlFragmentSource, sourcePrunableYamlContainers } from "./omp-yaml-source"; +import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock"; export type RefusalReason = | "not_installed" @@ -66,6 +68,8 @@ export interface IntegrationWriteInput { home?: string; store?: IntegrationStateStore; io?: IntegrationIO; + /** Frozen once by the async coordinator; synchronous callers may omit it. */ + resolvedPaths?: { configPath: string; detectDir: string }; } export interface IntegrationRestoreInput extends IntegrationWriteInput { @@ -169,11 +173,13 @@ function snapshotAbsPath(store: IntegrationStateStore, entry: JournalEntry): str return snapshot.kind === "stored" ? snapshot.path : undefined; } -function ompFragmentValue(contribution: ManagedContribution): unknown | undefined { +function sourcePreservingFragmentValue( + contribution: ManagedContribution, + path: readonly string[], +): unknown | undefined { const fragment = contribution.fragments.find(item => ( - item.path.length === 2 - && item.path[0] === "providers" - && item.path[1] === "opencodex" + item.path.length === path.length + && item.path.every((key, index) => key === path[index]) )); return fragment?.value; } @@ -194,7 +200,7 @@ function preflight(input: IntegrationWriteInput) { */ let configPath: string; try { - configPath = spec.configPath(input.env, input.home); + configPath = input.resolvedPaths?.configPath ?? spec.configPath(input.env, input.home); } catch (error) { if (!(error instanceof ClientPathError)) throw error; return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const; @@ -239,7 +245,7 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome { if (pre.failed) return pre.failed; const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre; - if (io.statKind(spec.detectDir(input.env, input.home)) !== "dir") { + if (io.statKind(input.resolvedPaths?.detectDir ?? spec.detectDir(input.env, input.home)) !== "dir") { return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`); } if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) { @@ -294,11 +300,16 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome { const nextDocument = mergeContribution(base, contribution); let text: string; try { - if (clientId === "omp" && before !== null) { - const value = ompFragmentValue(contribution); + if (spec.sourcePreservingYaml && before !== null) { + const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path); const patched = value === undefined ? null - : patchOmpYamlSource(before, { kind: "upsert", value }, nextDocument); + : patchYamlFragmentSource( + before, + spec.sourcePreservingYaml.path, + { kind: "upsert", value }, + nextDocument, + ); if (patched === null) { return refuse(clientId, "unsafe", "unsafe", `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`); @@ -344,7 +355,7 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome { export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { const pre = preflight(input); if (pre.failed) return pre.failed; - const { store, io, clientId, exportSpec, configPath, before, parsed, record, classified } = pre; + const { store, io, clientId, spec, exportSpec, configPath, before, parsed, record, classified } = pre; if (classified.state === "absent") { return { ok: true, changed: false, state: "absent", clientId, message: "not applied" }; @@ -377,21 +388,32 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { * json client classifies a sibling edit as stale (#1631) — which is why the * removal runs against the document as parsed NOW, and the re-serialize is * value-safe because non-round-tripping numbers were refused at parse time. + * Source-preserving YAML clients additionally compute which recorded + * containers are still source-empty before pruning, so a later sibling or + * comment makes its ancestor user-owned without protecting our leaf. */ + const recordedCreated = record!.createdContainers ?? []; + const prunableCreated = spec.sourcePreservingYaml && before !== null + ? sourcePrunableYamlContainers(before, spec.sourcePreservingYaml.path, recordedCreated) + : recordedCreated; + if (prunableCreated === null) { + return refuse(clientId, "unsafe", "unsafe", + `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); + } const { doc, removed } = removeFragments( parsed, record!.fragmentPaths, - new Set(record!.createdContainers ?? []), + new Set(prunableCreated), ); if (!removed) { return { ok: true, changed: false, state: "absent", clientId, message: "nothing to remove" }; } let text: string; try { - if (clientId === "omp" && before !== null) { - const patched = patchOmpYamlSource(before, { + if (spec.sourcePreservingYaml && before !== null) { + const patched = patchYamlFragmentSource(before, spec.sourcePreservingYaml.path, { kind: "remove", - removeEmptyProviders: record!.createdContainers?.includes("providers") === true, + createdContainers: prunableCreated, }, doc); if (patched === null) { return refuse(clientId, "unsafe", "unsafe", @@ -435,7 +457,8 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome if (entry.clientId !== input.clientId) throw new Error("restore input names a different client than the operation"); const clientId = entry.clientId; - const resolvedPath = INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home); + const resolvedPath = input.resolvedPaths?.configPath + ?? INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home); // Restore acts on the path the operation was journaled against. Resolving a // different path here would let an operation recorded for one home delete a // file in another. @@ -533,3 +556,105 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome snapshotPath: snapshotAbsPath(store, restoreEntry), }); } + +export interface CoordinatedIntegrationOptions { + lockSeams?: IntegrationWriterLockSeams; +} + +/** Freeze all mutable resolution seams before the first lock await. */ +type FrozenIntegrationInput = IntegrationWriteInput & { + store: IntegrationStateStore; + io: IntegrationIO; + env: NodeJS.ProcessEnv; + home: string; + resolvedPaths: { configPath: string; detectDir: string }; +}; + +function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegrationInput { + const env = { ...(input.env ?? process.env) }; + const home = input.home ?? homedir(); + const store = input.store ?? createIntegrationStateStore(); + const io = input.io ?? defaultIntegrationIO(store); + const spec = INTEGRATION_CLIENTS[input.clientId]; + const resolvedPaths = { + configPath: spec.configPath(env, home), + detectDir: spec.detectDir(env, home), + }; + return { ...input, env, home, store, io, resolvedPaths }; +} + +function tryFreezeIntegrationInput(input: IntegrationWriteInput): + | { ok: true; value: FrozenIntegrationInput } + | { ok: false; refusal: WriteRefused } { + try { + return { ok: true, value: freezeIntegrationInput(input) }; + } catch (error) { + if (!(error instanceof ClientPathError)) throw error; + return { + ok: false, + refusal: refuse(input.clientId, "unsafe", "unsafe", error.message), + }; + } +} + +async function coordinatedWrite( + input: IntegrationWriteInput, + operation: (frozen: IntegrationWriteInput) => WriteOutcome, + options?: CoordinatedIntegrationOptions, +): Promise { + const prepared = tryFreezeIntegrationInput(input); + if (!prepared.ok) return prepared.refusal; + const frozen = prepared.value; + const spec = INTEGRATION_CLIENTS[frozen.clientId]; + if (!spec.writerLock) return operation(frozen); + + // An absent client home is not created merely to acquire a sibling lock. + if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { + return operation(frozen); + } + return withIntegrationWriterLock( + frozen.resolvedPaths.configPath, + async () => operation(frozen), + options?.lockSeams, + spec.writerLock.suffix, + ); +} + +export function applyIntegrationCoordinated( + input: IntegrationWriteInput, + options?: CoordinatedIntegrationOptions, +): Promise { + return coordinatedWrite(input, applyIntegration, options); +} + +export function disableIntegrationCoordinated( + input: IntegrationWriteInput, + options?: CoordinatedIntegrationOptions, +): Promise { + return coordinatedWrite(input, disableIntegration, options); +} + +export async function restoreIntegrationCoordinated( + input: IntegrationRestoreInput, + options?: CoordinatedIntegrationOptions, +): Promise { + const prepared = tryFreezeIntegrationInput(input); + if (!prepared.ok) return prepared.refusal; + const frozen = prepared.value; + const spec = INTEGRATION_CLIENTS[frozen.clientId]; + if (!spec.writerLock) return restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); + if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { + return refuse( + frozen.clientId, + "unsafe", + "unsafe", + `${frozen.resolvedPaths.detectDir} is missing; restore will not create the client home`, + ); + } + return withIntegrationWriterLock( + frozen.resolvedPaths.configPath, + async () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }), + options?.lockSeams, + spec.writerLock.suffix, + ); +} diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index aec0fc2ca..de516e4ef 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -19,13 +19,14 @@ import { import { readIntegrationState } from "../../integrations/state"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store"; import { - applyIntegration, - disableIntegration, - restoreIntegration, + applyIntegrationCoordinated, + disableIntegrationCoordinated, + restoreIntegrationCoordinated, type IntegrationRestoreInput, type IntegrationWriteInput, type WriteRefused, } from "../../integrations/writer"; +import { IntegrationWriterLockBusyError, type IntegrationWriterLockSeams } from "../../integrations/writer-lock"; import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; @@ -37,9 +38,9 @@ const INTEGRATION_MUTATION_JOIN_MS = 120_000; export const INTEGRATION_MUTATION_TERMINAL_MS = 10 * 60_000; type IntegrationStateRecord = Awaited>; -type ApplyResult = Awaited>; -type DisableResult = Awaited>; -type RestoreResult = Awaited>; +type ApplyResult = Awaited>; +type DisableResult = Awaited>; +type RestoreResult = Awaited>; export type IntegrationStateEnvelope = { clientId: IntegrationClientId; @@ -95,6 +96,7 @@ class IntegrationMutationBusyError extends Error { const integrationMutationFlights = new Map(); let integrationMutationTestHooks: { io?: IntegrationIO; + lockSeams?: IntegrationWriterLockSeams; /** * Bind every read and write in the request to one store. Without this a * route test could isolate the writer but not the journal listing or the @@ -185,6 +187,7 @@ function runIntegrationMutationFlight( export function setIntegrationMutationFlightTestHooks( hooks: { io?: IntegrationIO; + lockSeams?: IntegrationWriterLockSeams; /** Binds the WHOLE request — reads, writes and journal — to one store. */ store?: IntegrationStateStore; run?: (operation: () => Promise) => Promise; @@ -433,6 +436,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise Promise.resolve(restoreIntegration(restoreInput)), + () => restoreIntegrationCoordinated(restoreInput, { + lockSeams: integrationMutationTestHooks?.lockSeams, + }), ); if (!result.ok) { /* @@ -478,11 +485,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise Promise.resolve(parsed.enabled - ? applyIntegration(input) - : disableIntegration(input)), + () => parsed.enabled + ? applyIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams }) + : disableIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams }), ); if (!result.ok) return writerFailureResponse(requestedClient, result, ctx); return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config); } catch (error) { - if (error instanceof IntegrationMutationBusyError) { + if (error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError) { return jsonResponse({ error: "integration mutation busy", code: "integration_mutation_busy", - clientId: error.clientId, + clientId: requestedClient, }, 409, req, ctx.config); } return internalErrorResponse(error, ctx); diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts index 0518362fa..a00c0708d 100644 --- a/tests/cli-export-command.test.ts +++ b/tests/cli-export-command.test.ts @@ -33,7 +33,16 @@ function config(extra?: Partial): OcxConfig { /** Rows in the shape GET /api/models actually returns, including a disabled one. */ const ROWS = [ - { provider: "openai", id: "gpt-5.6-luna", namespaced: "gpt-5.6-luna", native: true, disabled: false, contextWindow: 272_000 }, + { + provider: "openai", + id: "gpt-5.6-luna", + namespaced: "gpt-5.6-luna", + native: true, + disabled: false, + contextWindow: 272_000, + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + defaultReasoningEffort: "high", + }, { provider: "anthropic", id: "claude-opus-5", namespaced: "anthropic/claude-opus-5", disabled: false, contextWindow: 200_000, displayName: "Claude Opus 5" }, { provider: "custom", id: "no-context", namespaced: "custom/no-context", disabled: false }, { provider: "banned", id: "hidden", namespaced: "banned/hidden", disabled: true, contextWindow: 100_000 }, @@ -206,7 +215,7 @@ describe("ocx export argument validation (accept criterion 4)", () => { const proxy = fakeProxy(); const result = await run(["--client", "cursor"], { baseUrl: proxy.baseUrl }); expect(result.code).toBe(2); - for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae"]) { + for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh"]) { expect(result.stderr).toContain(id); } expect(result.stdout).toBe(""); @@ -328,4 +337,10 @@ describe("export row filtering", () => { expect(models[0]!.namespaced).toBe("a/two"); expect(models[0]!.inputModalities).toEqual(["text", "image"]); }); + + test("preserves reasoning metadata with management-loader parity", () => { + const [model] = exportModelsFromProxyRows([ROWS[0]!], config()); + expect(model?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(model?.defaultReasoningEffort).toBe("high"); + }); }); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 2eba8f9c1..039472de4 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -63,6 +63,19 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("Start the proxy server and sync models to Codex."); }); + test("top-level and export help advertise all eight clients including DSH", () => { + const topLevel = runCli([]); + expectSpawnFinished(topLevel, "ocx help"); + expect(topLevel.status).toBe(0); + expect(topLevel.stdout).toContain("(8 clients)"); + + const exportHelp = runCli(["help", "export"]); + expectSpawnFinished(exportHelp, "ocx help export"); + expect(exportHelp.status).toBe(0); + expect(exportHelp.stdout).toContain("opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh"); + expect(exportHelp.stdout).toContain("DeepSeek Harness"); + }); + test("top-level help forms exit before Codex shim auto-restore can mutate launchers", () => { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-help-shim-home-")); const binDir = mkdtempSync(join(tmpdir(), "ocx-help-shim-bin-")); diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index 81003fb5a..8842af56d 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -62,7 +62,7 @@ describe("no secret reaches a client config", () => { // carry provider headers, but remote credential wiring is deliberately // deferred from this initial generated integration. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { @@ -267,7 +267,7 @@ describe("gajae", () => { describe("contributions name every fragment we own", () => { test("single-entry clients own exactly one path", () => { - for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "gajae"] as const) { + for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "gajae", "dsh"] as const) { expect(buildClientContribution(id, ctx()).fragments).toHaveLength(1); } }); diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index a5fc20c88..be8491924 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -15,6 +15,7 @@ import { isExportClientId, normalizeExportModels, ompModelsConfigPath, + type DshGeneratedConfig, type ExportContext, type ExportModel, type OpencodeGeneratedConfig, @@ -76,6 +77,10 @@ function piConfig(context: ExportContext = ctx()): PiGeneratedConfig { return buildClientConfig("pi", context) as PiGeneratedConfig; } +function dshConfig(context: ExportContext = ctx()): DshGeneratedConfig { + return buildClientConfig("dsh", context) as DshGeneratedConfig; +} + describe("relocated OpenCode serializer (accept criterion 1)", () => { test("the moved builder reproduces the pre-refactor golden byte-for-byte", () => { @@ -309,6 +314,97 @@ describe("OMP serializer", () => { }); }); +describe("DSH rc.6 serializer", () => { + test("owns only llm-pi-ai.providers.opencodex with a loopback Responses profile", () => { + const document = dshConfig(); + expect(Object.keys(document)).toEqual(["llm-pi-ai"]); + expect(Object.keys(document["llm-pi-ai"].providers)).toEqual(["opencodex"]); + const provider = document["llm-pi-ai"].providers.opencodex!; + expect(provider.displayName).toBe("OpenCodex"); + expect(provider.api).toBe("openai-responses"); + expect(provider.baseURL).toBe(BASE_URL); + expect(provider.headers).toEqual({ Authorization: "Bearer ocx_data_dsh" }); + expect(provider).not.toHaveProperty("apiKeyEnv"); + expect(document).not.toHaveProperty("agent-default-model"); + expect(document).not.toHaveProperty("deepseek-official"); + }); + + test("exports only representable modalities and authoritative context windows", () => { + const document = dshConfig(ctx({ + models: [ + { namespaced: "a/unknown", provider: "a", id: "unknown", inputModalities: ["video"] }, + { namespaced: "a/mixed", provider: "a", id: "mixed", inputModalities: ["audio", "image", "image"] }, + { namespaced: "a/audio", provider: "a", id: "audio", inputModalities: ["audio"] }, + { namespaced: "a/context", provider: "a", id: "context", contextWindow: 128_000.9 }, + { namespaced: "a/fraction", provider: "a", id: "fraction", contextWindow: 0.5 }, + { namespaced: "a/zero", provider: "a", id: "zero", contextWindow: 0 }, + ], + })); + const models = document["llm-pi-ai"].providers.opencodex!.models; + expect(models.map(model => model.id)).toEqual(["a/context", "a/fraction", "a/mixed", "a/unknown", "a/zero"]); + expect(models.find(model => model.id === "a/unknown")?.input).toEqual(["text"]); + expect(models.find(model => model.id === "a/mixed")?.input).toEqual(["image"]); + expect(models.find(model => model.id === "a/context")?.contextWindow).toBe(128_000); + expect(models.find(model => model.id === "a/fraction")).not.toHaveProperty("contextWindow"); + expect(models.find(model => model.id === "a/zero")).not.toHaveProperty("contextWindow"); + for (const model of models) expect(model).not.toHaveProperty("maxTokens"); + }); + + test("maps only authoritative effort levels to DSH level-to-wire entries", () => { + const document = dshConfig(ctx({ + models: [ + { + namespaced: "a/reasoning", + provider: "a", + id: "reasoning", + reasoningEfforts: ["off", "minimal", " LOW ", "medium", "high", "xhigh", "ultra", "unknown", "low"], + }, + { namespaced: "a/ultra", provider: "a", id: "ultra", reasoningEfforts: ["ultra"] }, + { namespaced: "a/none", provider: "a", id: "none", reasoningEfforts: ["off", "minimal"] }, + ], + })); + const models = document["llm-pi-ai"].providers.opencodex!.models; + expect(models.find(model => model.id === "a/reasoning")?.reasoningEfforts).toEqual({ + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "ultra", + }); + expect(models.find(model => model.id === "a/ultra")?.reasoningEfforts).toEqual({ max: "ultra" }); + expect(models.find(model => model.id === "a/none")).not.toHaveProperty("reasoningEfforts"); + expect(JSON.stringify(document)).toContain('"ultra"'); + expect(JSON.stringify(document)).not.toContain('"minimal"'); + expect(JSON.stringify(document)).not.toContain('"off"'); + }); + + test("Direct drops native OpenAI and OpenAI-backed combos, failing closed on unknown combos", () => { + const models: ExportModel[] = [ + { namespaced: "gpt-5.6-luna", provider: "openai", id: "gpt-5.6-luna", native: true }, + { namespaced: "openai/gpt-routed", provider: "openai", id: "gpt-routed" }, + { namespaced: "safe/model", provider: "safe", id: "model" }, + { namespaced: "combo/safe", provider: "combo", id: "safe" }, + { namespaced: "combo/mixed", provider: "combo", id: "mixed" }, + { namespaced: "combo/unknown", provider: "combo", id: "unknown" }, + ]; + const direct = cfg({ + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", codexAccountMode: "direct" }, + safe: { adapter: "openai-chat", baseUrl: "https://safe.example/v1" }, + }, + combos: { + safe: { targets: [{ provider: "safe", model: "model" }] }, + mixed: { targets: [{ provider: "safe", model: "model" }, { provider: "openai", model: "gpt-5.6-luna" }] }, + }, + }); + const ids = dshConfig(ctx({ models, config: direct }))["llm-pi-ai"].providers.opencodex!.models.map(model => model.id); + expect(ids).toEqual(["combo/safe", "safe/model"]); + + const pool = cfg({ ...direct, providers: { ...direct.providers, openai: { ...direct.providers.openai!, codexAccountMode: "pool" } } }); + expect(dshConfig(ctx({ models, config: pool }))["llm-pi-ai"].providers.opencodex!.models).toHaveLength(models.length); + }); +}); + describe("no credential ever reaches the output (accept criterion 3)", () => { const LIVE_KEY = "ocx_live_do_not_serialize_0123456789"; @@ -317,7 +413,9 @@ describe("no credential ever reaches the output (accept criterion 3)", () => { const context = ctx({ config: withKey }); for (const client of EXPORT_CLIENT_IDS) { const serialized = JSON.stringify(buildClientConfig(client, context)); - expect(serialized).not.toContain("ocx_"); + // DSH requires this fixed non-secret loopback bearer. Remove only that + // exact placeholder before checking that no credential-shaped value leaked. + expect(serialized.replaceAll("ocx_data_dsh", "")).not.toContain("ocx_"); expect(serialized).not.toContain(LIVE_KEY); } }); @@ -370,8 +468,8 @@ describe("stable ordering (accept criterion 4)", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the seven file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae"]); + test("covers exactly the eight file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/dsh-path-contract.test.ts b/tests/dsh-path-contract.test.ts new file mode 100644 index 000000000..f6125649f --- /dev/null +++ b/tests/dsh-path-contract.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + dshConfigPath, + dshHomeDir, +} from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../src/integrations/registry"; + +const HOME = join(tmpdir(), "ocx-dsh-path-home"); + +describe("DSH 0.1.0-rc.6 path contract", () => { + test("blank or absent DSH_HOME falls back to ~/.dsh", () => { + for (const value of [undefined, "", " ", "\t\n"]) { + const env = value === undefined ? {} : { DSH_HOME: value }; + expect(dshHomeDir(env, HOME)).toBe(join(HOME, ".dsh")); + expect(dshConfigPath(env, HOME)).toBe(join(HOME, ".dsh", "settings.yaml")); + } + }); + + test("supports exact ~, slash and backslash home forms", () => { + expect(dshHomeDir({ DSH_HOME: "~" }, HOME)).toBe(HOME); + expect(dshHomeDir({ DSH_HOME: "~/profiles/work" }, HOME)).toBe(join(HOME, "profiles", "work")); + expect(dshHomeDir({ DSH_HOME: "~\\profiles\\work" }, HOME)).toBe(join(HOME, "profiles\\work")); + }); + + test("preserves a nonblank raw absolute value instead of trimming it", () => { + const absolute = join(tmpdir(), "dsh home"); + expect(dshHomeDir({ DSH_HOME: absolute }, HOME)).toBe(absolute); + expect(dshHomeDir({ DSH_HOME: `${absolute} ` }, HOME)).toBe(`${absolute} `); + expect(dshHomeDir({ DSH_HOME: join(absolute, "..", "normalized") }, HOME)).toBe( + join(tmpdir(), "normalized"), + ); + expect(() => dshHomeDir({ DSH_HOME: ` ${absolute}` }, HOME)).toThrow(ClientPathError); + }); + + test("refuses other relative selectors", () => { + for (const value of [".dsh", "profiles/work", "../dsh"]) { + expect(() => dshHomeDir({ DSH_HOME: value }, HOME)).toThrow(ClientPathError); + } + }); + + test("export and integration registries resolve the same settings file and detect directory", () => { + const env = { DSH_HOME: "~/profiles/work" }; + const expectedHome = join(HOME, "profiles", "work"); + expect(INTEGRATION_CLIENTS.dsh.detectDir(env, HOME)).toBe(expectedHome); + expect(INTEGRATION_CLIENTS.dsh.configPath(env, HOME)).toBe(join(expectedHome, "settings.yaml")); + expect(EXPORT_CLIENTS.dsh.filename).toBe("settings.yaml"); + expect(EXPORT_CLIENTS.dsh.destination(env)).toBe(dshConfigPath(env)); + }); + + test("checked-in fixture is a secret-free parseable rc.6 settings namespace", () => { + const text = readFileSync(new URL("./fixtures/dsh-settings-0.1.0-rc.6.yaml", import.meta.url), "utf8"); + const parsed = Bun.YAML.parse(text) as Record; + expect(parsed).toHaveProperty("llm-pi-ai.providers.acme-gateway.api", "openai-completions"); + expect(parsed).toHaveProperty("llm-pi-ai.providers.acme-gateway.models.1.reasoningEfforts.max", "ultra"); + expect(text).not.toContain("Bearer "); + expect(text).not.toContain("api_key"); + }); +}); diff --git a/tests/dsh-rc6-compat-script.test.ts b/tests/dsh-rc6-compat-script.test.ts new file mode 100644 index 000000000..d25df775c --- /dev/null +++ b/tests/dsh-rc6-compat-script.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { + DSH_RC6_VERSION, + assertExpectedDshVersion, + buildDshChildEnv, + renderDshSettings, +} from "../scripts/dsh-rc6-compat-e2e"; + +describe("optional DSH rc.6 compatibility E2E helpers", () => { + test("pins the exact developer-preview version", () => { + expect(DSH_RC6_VERSION).toBe("0.1.0-rc.6"); + expect(() => assertExpectedDshVersion("0.1.0-rc.6\n")).not.toThrow(); + expect(() => assertExpectedDshVersion("0.1.0-rc.7\n")).toThrow("expected DSH 0.1.0-rc.6"); + }); + + test("renders only a loopback OpenCodex endpoint into the checked-in template", () => { + const template = [ + "agent-default-model:", + " model: dsh-e2e-reasoner", + "llm-pi-ai:", + " providers:", + " opencodex:", + " baseURL: __OCX_BASE_URL__", + ].join("\n"); + + const rendered = renderDshSettings(template, "http://127.0.0.1:18181/v1"); + expect(rendered).toContain("baseURL: http://127.0.0.1:18181/v1"); + expect(rendered).not.toContain("__OCX_BASE_URL__"); + expect(() => renderDshSettings(template, "https://gateway.example/v1")) + .toThrow("loopback OpenCodex URL"); + expect(() => renderDshSettings(template.replace("__OCX_BASE_URL__", "already-set"), "http://localhost:18181/v1")) + .toThrow("exactly one __OCX_BASE_URL__ placeholder"); + }); + + test("isolates DSH state and strips proxy and provider credentials", () => { + const env = buildDshChildEnv({ + PATH: "/test/bin", + HOME: "/real/home", + DSH_HOME: "/real/dsh", + HTTP_PROXY: "http://proxy.invalid", + HTTPS_PROXY: "http://proxy.invalid", + ALL_PROXY: "socks://proxy.invalid", + OPENAI_API_KEY: "must-not-leak", + ANTHROPIC_API_KEY: "must-not-leak", + DEEPSEEK_API_KEY: "must-not-leak", + UNRELATED_FLAG: "kept", + }, { + home: "/tmp/dsh-e2e-home", + dshHome: "/tmp/dsh-e2e-dsh", + }); + + expect(env).toMatchObject({ + PATH: "/test/bin", + HOME: "/tmp/dsh-e2e-home", + DSH_HOME: "/tmp/dsh-e2e-dsh", + DSH_TELEMETRY_DISABLED: "1", + NO_PROXY: "127.0.0.1,localhost,::1", + UNRELATED_FLAG: "kept", + }); + expect(env.HTTP_PROXY).toBeUndefined(); + expect(env.HTTPS_PROXY).toBeUndefined(); + expect(env.ALL_PROXY).toBeUndefined(); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.DEEPSEEK_API_KEY).toBeUndefined(); + }); +}); diff --git a/tests/dsh-writer-lock.test.ts b/tests/dsh-writer-lock.test.ts new file mode 100644 index 000000000..410af773c --- /dev/null +++ b/tests/dsh-writer-lock.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExportModel } from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../src/integrations/store"; +import { + applyIntegrationCoordinated, + disableIntegrationCoordinated, + restoreIntegrationCoordinated, + type IntegrationWriteInput, +} from "../src/integrations/writer"; +import { + IntegrationWriterLockBusyError, + IntegrationWriterLockIOError, + withIntegrationWriterLock, + type IntegrationWriterLockSeams, +} from "../src/integrations/writer-lock"; +import type { OcxConfig } from "../src/types"; + +function eexist(): Error & { code: string } { + return Object.assign(new Error("exists"), { code: "EEXIST" }); +} + +describe("DSH sibling writer lock", () => { + test("uses wx, 0600, the PID line, bounded backoff, and releases only its own lock", async () => { + let now = 0; + const attempts: Array<{ path: string; payload: string; flag: string; mode: number }> = []; + const delays: number[] = []; + const removed: string[] = []; + const seams: IntegrationWriterLockSeams = { + writeFile: async (path, payload, options) => { + attempts.push({ path, payload, flag: options.flag, mode: options.mode }); + if (attempts.length < 4) throw eexist(); + }, + removeFile: async path => { removed.push(path); }, + now: () => now, + delay: async ms => { delays.push(ms); now += ms; }, + pid: 4242, + }; + + await expect(withIntegrationWriterLock("/tmp/settings.yaml", async () => "ok", seams)).resolves.toBe("ok"); + expect(attempts.every(item => item.path === "/tmp/settings.yaml.lock")).toBe(true); + expect(attempts.every(item => item.payload === "4242\n" && item.flag === "wx" && item.mode === 0o600)).toBe(true); + expect(delays).toEqual([20, 40, 80]); + expect(removed).toEqual(["/tmp/settings.yaml.lock"]); + }); + + test("times out as typed busy without deleting a contender", async () => { + let now = 0; + let removes = 0; + const seams: IntegrationWriterLockSeams = { + writeFile: async () => { throw eexist(); }, + removeFile: async () => { removes += 1; }, + now: () => now, + delay: async ms => { now += ms; }, + pid: 7, + }; + await expect(withIntegrationWriterLock("/tmp/settings.yaml", async () => undefined, seams)) + .rejects.toBeInstanceOf(IntegrationWriterLockBusyError); + expect(now).toBe(2_100); + expect(removes).toBe(0); + }); + + test("non-EEXIST acquisition and release failures are typed internal errors", async () => { + const acquire: IntegrationWriterLockSeams = { + writeFile: async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); }, + removeFile: async () => {}, now: () => 0, delay: async () => {}, pid: 1, + }; + await expect(withIntegrationWriterLock("/tmp/settings.yaml", async () => undefined, acquire)) + .rejects.toBeInstanceOf(IntegrationWriterLockIOError); + + const release = { ...acquire, + writeFile: async () => {}, + removeFile: async () => { throw new Error("release failed"); }, + }; + await expect(withIntegrationWriterLock("/tmp/settings.yaml", async () => undefined, release)) + .rejects.toBeInstanceOf(IntegrationWriterLockIOError); + }); + + test("releases after the protected operation throws", async () => { + let removes = 0; + const seams: IntegrationWriterLockSeams = { + writeFile: async () => {}, removeFile: async () => { removes += 1; }, + now: () => 0, delay: async () => {}, pid: 1, + }; + await expect(withIntegrationWriterLock("/tmp/settings.yaml", async () => { throw new Error("boom"); }, seams)) + .rejects.toThrow("boom"); + expect(removes).toBe(1); + }); +}); + +const MODELS: ExportModel[] = [ + { namespaced: "openai/gpt-5.5", provider: "openai", id: "gpt-5.5", contextWindow: 400_000 }, +]; +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +let root: string; +let home: string; +let store: IntegrationStateStore; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-dsh-lock-")); + home = join(root, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(root, "state", "integrations")); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function writeInput(env: NodeJS.ProcessEnv = {}): IntegrationWriteInput { + return { clientId: "dsh", models: MODELS, config: CONFIG, port: 10100, env, home, store }; +} + +function immediateLock(onWrite?: (path: string) => void): IntegrationWriterLockSeams { + return { + writeFile: async path => { onWrite?.(path); }, + removeFile: async () => {}, + now: () => 0, + delay: async () => {}, + pid: 123, + }; +} + +describe("DSH coordinated mutations", () => { + test("missing home apply/disable do not create a directory or lock", async () => { + let acquisitions = 0; + const seams = immediateLock(() => { acquisitions += 1; }); + const applied = await applyIntegrationCoordinated(writeInput(), { lockSeams: seams }); + expect(applied.ok).toBe(false); + if (!applied.ok) expect(applied.reason).toBe("not_installed"); + const disabled = await disableIntegrationCoordinated(writeInput(), { lockSeams: seams }); + expect(disabled).toMatchObject({ ok: true, changed: false, state: "absent" }); + expect(acquisitions).toBe(0); + expect(existsSync(INTEGRATION_CLIENTS.dsh.detectDir({}, home))).toBe(false); + }); + + test("an installed home with no settings locks, creates owner-only settings, and locks an apply no-op", async () => { + const dshHome = INTEGRATION_CLIENTS.dsh.detectDir({}, home); + mkdirSync(dshHome, { recursive: true }); + let acquisitions = 0; + const seams = immediateLock(() => { acquisitions += 1; }); + expect((await applyIntegrationCoordinated(writeInput(), { lockSeams: seams })).ok).toBe(true); + const configPath = INTEGRATION_CLIENTS.dsh.configPath({}, home); + expect(statSync(configPath).mode & 0o777).toBe(0o600); + const second = await applyIntegrationCoordinated(writeInput(), { lockSeams: seams }); + expect(second).toMatchObject({ ok: true, changed: false, state: "current" }); + expect(acquisitions).toBe(2); + }); + + test("freezes environment and path before awaiting lock acquisition", async () => { + const first = join(root, "first-dsh-home"); + const second = join(root, "second-dsh-home"); + mkdirSync(first, { recursive: true }); + mkdirSync(second, { recursive: true }); + const env = { DSH_HOME: first } as NodeJS.ProcessEnv; + let lockPath = ""; + const seams = immediateLock(path => { + lockPath = path; + env.DSH_HOME = second; + }); + expect((await applyIntegrationCoordinated(writeInput(env), { lockSeams: seams })).ok).toBe(true); + expect(lockPath).toBe(join(first, "settings.yaml.lock")); + expect(existsSync(join(first, "settings.yaml"))).toBe(true); + expect(existsSync(join(second, "settings.yaml"))).toBe(false); + }); + + test("restore refuses a missing parent without recreating or locking it", async () => { + const dshHome = INTEGRATION_CLIENTS.dsh.detectDir({}, home); + mkdirSync(dshHome, { recursive: true }); + expect((await applyIntegrationCoordinated(writeInput(), { lockSeams: immediateLock() })).ok).toBe(true); + const opId = store.listOperations("dsh")[0]!.opId; + rmSync(dshHome, { recursive: true, force: true }); + let acquisitions = 0; + const restored = await restoreIntegrationCoordinated( + { ...writeInput(), opId }, + { lockSeams: immediateLock(() => { acquisitions += 1; }) }, + ); + expect(restored).toMatchObject({ ok: false, reason: "unsafe" }); + expect(acquisitions).toBe(0); + expect(existsSync(dshHome)).toBe(false); + }); +}); diff --git a/tests/fixtures/dsh-rc6-compat-e2e-settings.yaml b/tests/fixtures/dsh-rc6-compat-e2e-settings.yaml new file mode 100644 index 000000000..4cd6d8320 --- /dev/null +++ b/tests/fixtures/dsh-rc6-compat-e2e-settings.yaml @@ -0,0 +1,23 @@ +# Optional local compatibility fixture for @deepseek-ai/dsh 0.1.0-rc.6. +# The runner replaces the loopback placeholder inside an isolated DSH_HOME. +agent-default-model: + provider: opencodex + model: dsh-e2e-reasoner + reasoningEffort: high + +llm-pi-ai: + providers: + opencodex: + displayName: OpenCodex E2E + api: openai-responses + baseURL: __OCX_BASE_URL__ + headers: + Authorization: Bearer ocx_data_dsh + models: + - id: dsh-e2e-reasoner + name: DSH E2E Reasoner + contextWindow: 32768 + input: + - text + reasoningEfforts: + high: high diff --git a/tests/fixtures/dsh-settings-0.1.0-rc.6.yaml b/tests/fixtures/dsh-settings-0.1.0-rc.6.yaml new file mode 100644 index 000000000..2a006cc37 --- /dev/null +++ b/tests/fixtures/dsh-settings-0.1.0-rc.6.yaml @@ -0,0 +1,24 @@ +# Schema fixture adapted from the installed @deepseek-ai/dsh-llm-pi-ai +# 0.1.0-rc.6 README Config example. This is not an official default file. +llm-pi-ai: + providers: + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + compat: + thinkingFormat: deepseek + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 + - id: acme-think + name: Acme Think + contextWindow: 262144 + maxTokens: 32768 + reasoningEfforts: + off: null + high: high + max: ultra diff --git a/tests/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index f7524214a..a6fa42749 100644 --- a/tests/integrations-invariants.test.ts +++ b/tests/integrations-invariants.test.ts @@ -66,9 +66,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same seven ids", async () => { + test("every list of clients holds exactly the same eight ids", async () => { /* - * Five lists name the same seven clients, and two of them are maintained by + * Five lists name the same eight clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -78,13 +78,22 @@ describe("the client registries cannot drift apart", () => { const guiIntegrations = await import("../gui/src/pages/integrations/integration-api"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(7); + expect(expected).toHaveLength(8); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); expect(Object.keys(gui.CLIENT_LABEL_KEYS).sort()).toEqual(expected); expect([...guiIntegrations.FILE_INTEGRATION_CLIENTS].sort()).toEqual(expected); }); + + test("source preservation and cross-process locking are registry capabilities", () => { + expect(INTEGRATION_CLIENTS.omp.sourcePreservingYaml?.path).toEqual(["providers", "opencodex"]); + expect(INTEGRATION_CLIENTS.dsh.sourcePreservingYaml?.path).toEqual([ + "llm-pi-ai", "providers", "opencodex", + ]); + expect(INTEGRATION_CLIENT_IDS.filter(id => INTEGRATION_CLIENTS[id].writerLock)).toEqual(["dsh"]); + expect(INTEGRATION_CLIENTS.dsh.writerLock).toEqual({ suffix: ".lock" }); + }); }); describe("the journal is metadata, never a copy of the file", () => { @@ -130,6 +139,7 @@ describe("every client survives a full lifecycle", () => { openclaw: '{\n models: {\n providers: {\n mine: { api: "http://keep-me" },\n },\n },\n}\n', kimi: '[providers.mine]\napi = "http://keep-me"\n', gajae: "providers:\n mine:\n api: http://keep-me\n", + dsh: "llm-pi-ai:\n providers:\n mine:\n api: openai-completions\n", }; for (const clientId of INTEGRATION_CLIENT_IDS) { diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index 2fbb7a3b0..1ddb55104 100644 --- a/tests/integrations-state.test.ts +++ b/tests/integrations-state.test.ts @@ -546,6 +546,36 @@ describe("ownership is scoped to recorded fragments", () => { expect(result).toEqual({ state: "current" }); }); + test("DSH also ignores whole-file edits outside its registry-declared fragment", () => { + const dshContribution = { + clientId: "dsh" as const, + fragments: [{ + path: ["llm-pi-ai", "providers", "opencodex"], + value: ownedValue, + }], + }; + const dshDocument = { + "agent-default-model": "user-edited-after-apply", + "llm-pi-ai": { providers: { opencodex: ownedValue } }, + }; + const dshRecord: OwnershipRecord = { + ...record, + clientId: "dsh", + configPath: "/tmp/settings.yaml", + blockFingerprint: fingerprint(canonicalContribution(dshContribution)), + fragmentPaths: [["llm-pi-ai", "providers", "opencodex"]], + }; + expect(classifyIntegration({ + fileText: "agent-default-model: user-edited-after-apply\n", + fileIsRegular: true, + parsed: dshDocument, + record: dshRecord, + contribution: dshContribution, + clientId: "dsh", + configPath: "/tmp/settings.yaml", + })).toEqual({ state: "current" }); + }); + test("an unrelated extra fragment remains stale when our catalog moves", () => { const newerContribution = { ...ownedContribution, @@ -684,9 +714,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi and gajae are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae and dsh are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/integrations-writer.test.ts b/tests/integrations-writer.test.ts index 34f3cd079..b0671b62e 100644 --- a/tests/integrations-writer.test.ts +++ b/tests/integrations-writer.test.ts @@ -88,6 +88,14 @@ function installOmp(): string { return configPath; } +function installDsh(): string { + const spec = INTEGRATION_CLIENTS.dsh; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + function input(overrides: Partial = {}): IntegrationWriteInput { return { clientId: "hermes", @@ -309,7 +317,7 @@ describe("apply", () => { }); test("refuses a loopback-only client on a remote bind without denying manual OMP headers", () => { - for (const clientId of ["gajae", "omp"] as const) { + for (const clientId of ["gajae", "omp", "dsh"] as const) { const spec = INTEGRATION_CLIENTS[clientId]; mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); const result = applyIntegration(input({ @@ -478,6 +486,80 @@ describe("OMP source preservation", () => { }); }); +describe("DSH source preservation", () => { + test("preserves defaults, namespaces, providers, comments, and formatting through refresh and disable", () => { + const configPath = installDsh(); + const original = [ + "# DSH user header", + "agent-default-model: deepseek-official/deepseek-chat", + "llm-pi-ai:", + " providers:", + " deepseek-official:", + " api: openai-completions # native stays", + "other-namespace:", + " compact: false", + "# DSH user tail", + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(applyIntegration(input({ clientId: "dsh" })).ok).toBe(true); + const applied = readFileSync(configPath, "utf8"); + expect(applied).toContain("agent-default-model: deepseek-official/deepseek-chat\n"); + expect(applied).toContain(" api: openai-completions # native stays\n"); + expect(applied).toContain("other-namespace:\n compact: false\n"); + + const externallyEdited = applied.replace("# DSH user header", "# DSH user header edited"); + writeFileSync(configPath, externallyEdited); + const refreshedModels = [...MODELS, { + namespaced: "openai/gpt-5.6", + provider: "openai", + id: "gpt-5.6", + contextWindow: 272_000, + }]; + expect(applyIntegration(input({ clientId: "dsh", models: refreshedModels })).ok).toBe(true); + expect(disableIntegration(input({ clientId: "dsh", models: refreshedModels })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe( + original.replace("# DSH user header", "# DSH user header edited"), + ); + }); + + test("an edit inside the owned DSH leaf conflicts and survives", () => { + const configPath = installDsh(); + expect(applyIntegration(input({ clientId: "dsh" })).ok).toBe(true); + const edited = readFileSync(configPath, "utf8").replace( + "api: openai-responses", + "api: user-edited", + ); + writeFileSync(configPath, edited); + const disabled = disableIntegration(input({ clientId: "dsh" })); + expect(disabled.ok).toBe(false); + if (!disabled.ok) expect(disabled.reason).toBe("conflict"); + expect(readFileSync(configPath, "utf8")).toBe(edited); + }); + + test("a sibling added below a container we created survives disable", () => { + const configPath = installDsh(); + writeFileSync(configPath, "agent-default-model: native\n"); + expect(applyIntegration(input({ clientId: "dsh" })).ok).toBe(true); + const applied = readFileSync(configPath, "utf8"); + const edited = applied.replace( + " opencodex:\n", + " user-provider:\n api: openai-completions # keep\n opencodex:\n", + ); + writeFileSync(configPath, edited); + expect(disableIntegration(input({ clientId: "dsh" })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe([ + "agent-default-model: native", + "llm-pi-ai:", + " providers:", + " user-provider:", + " api: openai-completions # keep", + "", + ].join("\n")); + }); +}); + describe("restore", () => { test("undoes an apply back to the exact prior bytes", () => { const configPath = installHermes(); diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index 93001778b..13d3ee375 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -8,6 +8,7 @@ import { buildClientConfig, normalizeExportModels, opencodeGlobalConfigPath, + type DshGeneratedConfig, type ExportModel, type OpencodeGeneratedConfig, type PiGeneratedConfig, @@ -43,6 +44,8 @@ interface ModelRow { displayName?: string; contextWindow?: number; inputModalities?: string[]; + reasoningEfforts?: string[]; + defaultReasoningEffort?: string; } /** @@ -109,6 +112,8 @@ function toExportModel(row: ModelRow): ExportModel { ...(row.displayName ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), + ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}), + ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}), }; } @@ -181,6 +186,25 @@ describe("GET /api/client-config", () => { expect(JSON.stringify(body.config)).not.toContain(REAL_LOOKING_KEY); }, 15_000); + test("DSH response keeps management reasoning metadata in the rc.6 model map", async () => { + const response = await clientConfigApi(baseConfig(), "?client=dsh"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + expect(body.filename).toBe("settings.yaml"); + expect(body.format).toBe("yaml"); + expect(Bun.YAML.parse(body.text)).toEqual(body.config as Record); + + const provider = (body.config as DshGeneratedConfig)["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]!; + const native = provider.models.find(model => model.id === "gpt-5.6-luna")!; + expect(native.reasoningEfforts).toEqual({ + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", + }); + }, 15_000); + test("counts describe the emitted document, including models without limits", async () => { const config = baseConfig(); const opencode = await (await clientConfigApi(config, "?client=opencode")).json() as ClientConfigEnvelope; diff --git a/tests/management-integration-routes.test.ts b/tests/management-integration-routes.test.ts index 0d573c1ff..67312d0b3 100644 --- a/tests/management-integration-routes.test.ts +++ b/tests/management-integration-routes.test.ts @@ -9,6 +9,7 @@ import { INTEGRATION_CLIENTS, INTEGRATION_CLIENT_IDS } from "../src/integrations import { readIntegrationState } from "../src/integrations/state"; import { createIntegrationStateStore, type IntegrationStateStore } from "../src/integrations/store"; import { applyIntegration } from "../src/integrations/writer"; +import type { IntegrationWriterLockSeams } from "../src/integrations/writer-lock"; import { handleManagementAPI } from "../src/server/management-api"; import { setIntegrationMutationFlightTestHooks, @@ -120,6 +121,12 @@ function installOmp(): string { return spec.configPath(routeEnv, home); } +function installDsh(): string { + const spec = INTEGRATION_CLIENTS.dsh; + mkdirSync(spec.detectDir(routeEnv, home), { recursive: true }); + return spec.configPath(routeEnv, home); +} + function hermesConfigPath(): string { return INTEGRATION_CLIENTS.hermes.configPath(routeEnv, home); } @@ -409,6 +416,60 @@ describe("single-flight", () => { }); }); +describe("cross-process integration writer lock mapping", () => { + test("an unresolvable DSH path stays a typed unsafe refusal before lock acquisition", async () => { + setIntegrationPathTestHooks({ env: { DSH_HOME: "relative/dsh-home" }, home }); + + const response = await put("dsh", true); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "integration_unsafe", + clientId: "dsh", + reason: "unsafe", + state: "unsafe", + }); + expect(store.listOperations()).toHaveLength(0); + }); + + test("DSH lock timeout maps to the existing busy 409 without a real wait", async () => { + const configPath = installDsh(); + let now = 0; + const lockSeams: IntegrationWriterLockSeams = { + writeFile: async () => { throw Object.assign(new Error("contended"), { code: "EEXIST" }); }, + removeFile: async () => { throw new Error("must not remove contender"); }, + now: () => now, + delay: async ms => { now += ms; }, + pid: 11, + }; + setIntegrationMutationFlightTestHooks({ store, lockSeams }); + const response = await put("dsh", true); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "integration mutation busy", + code: "integration_mutation_busy", + clientId: "dsh", + }); + expect(now).toBe(2_100); + expect(existsSync(configPath)).toBe(false); + }); + + test("non-contention lock IO maps to integration_internal_error", async () => { + const configPath = installDsh(); + const lockSeams: IntegrationWriterLockSeams = { + writeFile: async () => { throw Object.assign(new Error("permission denied"), { code: "EACCES" }); }, + removeFile: async () => {}, + now: () => 0, + delay: async () => {}, + pid: 12, + }; + setIntegrationMutationFlightTestHooks({ store, lockSeams }); + const response = await put("dsh", true); + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ code: "integration_internal_error" }); + expect(existsSync(configPath)).toBe(false); + }); +}); + /** Bookkeeping seams bound to the temp store, as `store.io()` does. */ function bookkeeping(): Pick { return { diff --git a/tests/yaml-fragment-source.test.ts b/tests/yaml-fragment-source.test.ts new file mode 100644 index 000000000..8fba2d1af --- /dev/null +++ b/tests/yaml-fragment-source.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { patchYamlFragmentSource } from "../src/integrations/omp-yaml-source"; + +const DSH_PATH = ["llm-pi-ai", "providers", "opencodex"] as const; +const VALUE = { api: "openai-responses", baseURL: "http://127.0.0.1:10100/v1" }; + +function upsert(text: string, expected: unknown) { + return patchYamlFragmentSource(text, DSH_PATH, { kind: "upsert", value: VALUE }, expected); +} + +describe("generic source-preserving YAML fragment mutation", () => { + test("inserts below the deepest existing container without changing siblings or comments", () => { + const source = [ + "# header", + "agent-default-model: deepseek-official/deepseek-chat", + "llm-pi-ai:", + " providers:", + " native:", + " api: openai-completions # keep", + "# tail", + "", + ].join("\n"); + const expected = Bun.YAML.parse(source) as Record; + ((expected["llm-pi-ai"] as { providers: Record }).providers).opencodex = VALUE; + + const patched = upsert(source, expected); + expect(patched).not.toBeNull(); + expect(patched).toContain("agent-default-model: deepseek-official/deepseek-chat\n"); + expect(patched).toContain(" api: openai-completions # keep\n"); + expect(patched).toStartWith("# header\n"); + expect(patched).toEndWith("# tail\n"); + }); + + test("creates every missing container and preserves CRLF plus missing final newline", () => { + const source = "agent-default-model: native\r\nother: keep"; + const expected = { + "agent-default-model": "native", + other: "keep", + "llm-pi-ai": { providers: { opencodex: VALUE } }, + }; + const patched = upsert(source, expected); + expect(patched).not.toBeNull(); + expect(patched!.replaceAll("\r\n", "")).not.toContain("\n"); + expect(patched).not.toEndWith("\n"); + expect(Bun.YAML.parse(patched!)).toEqual(expected); + }); + + test("refuses duplicate keys, tabs, flow/quoted containers, and comments inside the owned leaf", () => { + const unsafe = [ + "llm-pi-ai:\n providers:\n opencodex:\n api: one\n opencodex:\n api: two\n", + "llm-pi-ai:\n\tproviders: {}\n", + "llm-pi-ai: { providers: {} }\n", + "\"llm-pi-ai\":\n providers: {}\n", + "llm-pi-ai:\n providers:\n opencodex:\n # owned comment\n api: one\n", + ]; + for (const source of unsafe) { + expect(upsert(source, { "llm-pi-ai": { providers: { opencodex: VALUE } } })).toBeNull(); + } + }); + + test("remove prunes only recorded, source-empty ancestors", () => { + const generated = [ + "llm-pi-ai:", + " providers:", + " opencodex:", + " api: openai-responses", + "", + ].join("\n"); + const removed = patchYamlFragmentSource(generated, DSH_PATH, { + kind: "remove", + createdContainers: ["llm-pi-ai", "llm-pi-ai\0providers"], + }, {}); + expect(removed).toBe(""); + + const userContainer = "llm-pi-ai:\n providers: {}\n"; + expect(patchYamlFragmentSource(userContainer, DSH_PATH, { + kind: "remove", + createdContainers: [], + }, Bun.YAML.parse(userContainer))).toBeNull(); + }); + + test("remove keeps a created ancestor after a user adds a sibling", () => { + const source = [ + "llm-pi-ai:", + " providers:", + " opencodex:", + " api: openai-responses", + " later-user-provider:", + " api: openai-completions # keep", + "", + ].join("\n"); + const expected = { + "llm-pi-ai": { providers: { "later-user-provider": { api: "openai-completions" } } }, + }; + const patched = patchYamlFragmentSource(source, DSH_PATH, { + kind: "remove", + createdContainers: ["llm-pi-ai", "llm-pi-ai\0providers"], + }, expected); + expect(patched).toBe([ + "llm-pi-ai:", + " providers:", + " later-user-provider:", + " api: openai-completions # keep", + "", + ].join("\n")); + }); + + test("never accepts a candidate whose complete parsed document differs from expected", () => { + const source = "other: keep\n"; + expect(upsert(source, { other: "different", "llm-pi-ai": { providers: { opencodex: VALUE } } })).toBeNull(); + }); +}); From 56ddf25e1c5fb053029d1c05acb1f84b912f6c08 Mon Sep 17 00:00:00 2001 From: zhouxun Date: Fri, 14 Aug 2026 11:33:38 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E8=A1=A5=E5=85=85=20DSH=20=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=EF=BC=9A=E6=BB=A1=E8=B6=B3=20GUI=20=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pr-screenshots/1619-dsh-integration.png | Bin 0 -> 182838 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs-site/public/pr-screenshots/1619-dsh-integration.png diff --git a/docs-site/public/pr-screenshots/1619-dsh-integration.png b/docs-site/public/pr-screenshots/1619-dsh-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..c2823995c24f21adbd0240933433ea12ad417ca2 GIT binary patch literal 182838 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Ð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
#EQEJL1# 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 Date: Fri, 14 Aug 2026 13:46:38 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DSH=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E9=97=AE=E9=A2=98=EF=BC=9A=E8=A1=A5=E5=BC=BA=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E5=B7=A5=E5=85=B7=E4=B8=8E=E9=94=81=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/docs/ja/reference/cli/agents.md | 2 +- .../content/docs/ko/reference/cli/agents.md | 2 +- .../src/content/docs/reference/cli/agents.md | 3 + .../content/docs/ru/reference/cli/agents.md | 12 +-- .../content/docs/tr/reference/cli/agents.md | 4 +- .../docs/zh-cn/reference/cli/agents.md | 2 +- .../docs/zh-tw/reference/cli/agents.md | 2 + scripts/dsh-rc6-compat-e2e.ts | 81 ++++++++++++++----- src/integrations/writer-lock.ts | 23 ++++-- tests/dsh-path-contract.test.ts | 4 +- tests/dsh-rc6-compat-script.test.ts | 17 ++++ tests/dsh-writer-lock.test.ts | 37 ++++++++- tests/integrations-writer.test.ts | 5 ++ 13 files changed, 157 insertions(+), 37 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 642a1b5fa..7ea3a3deb 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -164,7 +164,7 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex `ocx export` は実際のクライアント設定を書き込むことはありません。宛先は手動でマージできるように出力されます。`--out` は、`--force` なしで既存のファイルを上書きすることを拒否します。これは、設定を置き換えると、その中にすでに含まれている他のプロバイダー、エージェント、および MCP エントリが破壊されるためです。 ::: -キーはシリアル化されません。opencode、Hermes、OpenClaw の設定には環境参照のみが含まれるためシークレットは環境内に残り、Pi、OMP、Kimi、Gajae、DSH の設定には認証情報ではなくループバック用プレースホルダーが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。生成される Pi、OMP、Kimi、Gajae、DSH 統合はループバック専用です。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 +キーはシリアル化されません。opencode、Hermes、OpenClaw、Gajae の設定には環境参照のみが含まれるためシークレットは環境内に残り、Pi、OMP、Kimi、DSH の設定には認証情報ではなくループバック用プレースホルダーが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。`OPENCODEX_GAJAE_API_KEY` は Gajae の provider 認証値を環境から渡しますが、remote admission header は送れないため、生成される Gajae 統合は Pi、OMP、Kimi、DSH と同様にループバック専用です。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 同じペイロードが `GET /api/client-config` によって提供され、ダッシュボードの [API] タブにレンダリングされるため、CLI、API、および GUI は同じバイトを使用します。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 36584c1ce..80325145c 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -170,7 +170,7 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 `ocx export`는 실제 client config를 절대 쓰지 않습니다. 대상 경로는 손으로 병합하라고 출력되며, `--out`은 `--force` 없이 기존 파일을 덮어쓰지 않습니다. config를 바꾸어 덮어쓰면 이미 들어 있던 다른 provider, agent, MCP entry가 사라지기 때문입니다. ::: -어떤 key도 직렬화되지 않습니다. opencode, Hermes, OpenClaw config에는 env reference만 들어가므로 secret은 환경 변수에 남고, Pi, OMP, Kimi, Gajae, DSH config에는 인증 정보가 아니라 loopback placeholder가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. 생성되는 Pi, OMP, Kimi, Gajae, DSH 통합은 loopback 전용입니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. +어떤 key도 직렬화되지 않습니다. opencode, Hermes, OpenClaw, Gajae config에는 env reference만 들어가므로 secret은 환경 변수에 남고, Pi, OMP, Kimi, DSH config에는 인증 정보가 아니라 loopback placeholder가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. `OPENCODEX_GAJAE_API_KEY`는 Gajae provider 인증 값을 환경에서 전달하지만 remote admission header를 보낼 수는 없으므로, 생성되는 Gajae 통합은 Pi, OMP, Kimi, DSH와 마찬가지로 loopback 전용입니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. 같은 payload는 `GET /api/client-config`로 제공되고 dashboard의 API 탭에도 렌더링되므로, CLI, API, GUI가 모두 같은 바이트를 사용합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index c773666b0..370fc639c 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -230,6 +230,9 @@ the proxy binds beyond loopback; see [Remote access](/reference/configuration/#remote-access) for how admission keys are issued. Keys for the upstream providers themselves are a separate thing entirely, configured per [Providers](/guides/providers/). +Gajae is the exception: `OPENCODEX_GAJAE_API_KEY` fills its provider credential from the +environment, but its schema cannot send the remote admission header, so the generated Gajae +integration remains loopback-only. The same payload is served by `GET /api/client-config` and rendered on the dashboard's API tab, so the CLI, the API, and the GUI use the same bytes. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 4d2e604d3..bba29e1f3 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -204,14 +204,16 @@ Pi не требует переменной окружения и несёт л MCP-записи. ::: -Никакой ключ никогда не сериализуется. Конфиги opencode, Hermes и OpenClaw несут только -env-reference, так что секрет остаётся в вашем окружении, а конфиги Pi, OMP, Kimi, Gajae и DSH несут +Никакой ключ никогда не сериализуется. Конфиги opencode, Hermes, OpenClaw и Gajae несут только +env-reference, так что секрет остаётся в вашем окружении, а конфиги Pi, OMP, Kimi и DSH несут loopback-заглушку вместо учётных данных. Loopback-прокси (`127.0.0.1`, по умолчанию) вообще не требует admission key. Если прокси слушает не на loopback, задайте соответствующую переменную `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY` или `OPENCODEX_OPENCLAW_API_KEY`. -Сгенерированные интеграции Pi, OMP, Kimi, Gajae и DSH работают только через loopback. Как выдаются -admission key, описано в [Удалённом доступе](/reference/configuration/#remote-access). Ключи -upstream-провайдеров — это совсем отдельная история и настраиваются в [Провайдерах](/guides/providers/). +`OPENCODEX_GAJAE_API_KEY` передаёт provider credential Gajae через окружение, но не позволяет +отправить remote admission header, поэтому сгенерированная интеграция Gajae, как и Pi, OMP, Kimi и +DSH, работает только через loopback. Как выдаются admission key, описано в +[Удалённом доступе](/reference/configuration/#remote-access). Ключи upstream-провайдеров — это совсем +отдельная история и настраиваются в [Провайдерах](/guides/providers/). Тот же payload отдаётся через `GET /api/client-config` и показывается на вкладке API в дашборде, поэтому CLI, API и GUI используют в точности одни и те же байты. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index d92a1f07a..82949d8f9 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -255,6 +255,9 @@ döngünün ötesine bağlandığında ayarlayın; kabul anahtarlarının nasıl görmek için [Uzaktan erişim](/tr/reference/configuration/#remote-access) bölümüne bakın. Yukarı akış sağlayıcılarının kendi anahtarları tamamen ayrı bir şeydir ve [Sağlayıcılar](/tr/guides/providers/) bölümüne göre yapılandırılır. +Gajae istisnadır: `OPENCODEX_GAJAE_API_KEY` provider kimlik bilgisini ortamdan +sağlar, ancak şeması uzaktan kabul başlığını gönderemediği için üretilen Gajae +entegrasyonu yalnızca geri döngüde çalışır. Aynı yük `GET /api/client-config` tarafından sunulur ve kontrol panelinin API sekmesinde işlenir; böylece CLI, API ve GUI aynı baytları kullanır. @@ -275,4 +278,3 @@ ocx system settings --stream-mode eager-relay Doğrulanmış OpenCodex yapılandırmasını inceleyin ve güvenle değiştirin. `show` ve `get` sırları maskeler. İçe aktarma yazmadan önce doğrular ve `--yes` gerektirir. - diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 5fc62efb3..db3754ba6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -171,7 +171,7 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi `ocx export` 从不写入你的真实客户端配置。该命令只会打印目标路径供你手动合并,而 `--out` 在没有 `--force` 的情况下拒绝覆盖已有文件,因为替换配置会破坏其中已有的其他 providers、agents 和 MCP 条目。 ::: -任何密钥都不会被序列化。opencode、Hermes 和 OpenClaw 配置里只包含环境引用,因此密钥仍保留在你的环境中;Pi、OMP、Kimi、Gajae 和 DSH 配置里携带的是环回占位值而不是任何凭据。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。生成的 Pi、OMP、Kimi、Gajae 和 DSH 集成仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 +任何密钥都不会被序列化。opencode、Hermes、OpenClaw 和 Gajae 配置里只包含环境引用,因此密钥仍保留在你的环境中;Pi、OMP、Kimi 和 DSH 配置里携带的是环回占位值而不是任何凭据。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。`OPENCODEX_GAJAE_API_KEY` 只会从环境中提供 Gajae provider 凭据,不能发送远程准入 header,因此生成的 Gajae 集成仍与 Pi、OMP、Kimi 和 DSH 一样仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 同一份负载会通过 `GET /api/client-config` 提供,并在仪表盘的 API 选项卡中渲染,因此 CLI、API 和 GUI 使用的是同一字节内容。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 71c47037b..ebe59cbcd 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -174,6 +174,8 @@ opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不 金鑰永不被序列化。設定只帶有文件化的環境變數參考,或非秘密的 loopback 佔位符。loopback 代理(`127.0.0.1`,預設值)完全不需要准入金鑰。只有客戶端 schema 支援、且代理綁定超出 loopback 時,才設定被引用的變數;關於准入金鑰的簽發方式,請見[遠端存取](/zh-tw/reference/configuration/#remote-access)。上游 provider 本身的金鑰是完全不同的事,依[供應商](/zh-tw/guides/providers/)個別設定。 +Gajae 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑證,但其 schema 無法傳送遠端准入 header,因此產生的 Gajae 整合仍僅支援 loopback。 + 相同的 payload 亦由 `GET /api/client-config` 提供,並在儀表板的 API 分頁渲染,因此 CLI、API 與 GUI 使用相同的位元組。 ## 執行階段與設定 diff --git a/scripts/dsh-rc6-compat-e2e.ts b/scripts/dsh-rc6-compat-e2e.ts index 0f55a4a8c..85d1dae5a 100644 --- a/scripts/dsh-rc6-compat-e2e.ts +++ b/scripts/dsh-rc6-compat-e2e.ts @@ -13,6 +13,10 @@ const RUN_TIMEOUT_MS = 60_000; const TEMPLATE_PLACEHOLDER = "__OCX_BASE_URL__"; const E2E_MODEL = "dsh-e2e-reasoner"; const MISSING_MODEL = "dsh-e2e-missing"; +export const DSH_E2E_TOOL_CALLS = [ + { id: "fc_dsh_e2e_alpha", callId: "call_dsh_e2e_alpha", filePath: "probe-alpha.txt", marker: "TOOL_ALPHA" }, + { id: "fc_dsh_e2e_beta", callId: "call_dsh_e2e_beta", filePath: "probe-beta.txt", marker: "TOOL_BETA" }, +] as const; const CREDENTIAL_ENV_PATTERN = /(?:_API_KEY|_API_TOKEN|_AUTH_TOKEN|_ACCESS_TOKEN|_SECRET_ACCESS_KEY)$/i; type StringEnv = Record; @@ -155,25 +159,51 @@ function usage() { } function toolResponse(): Response { - const item = { + const items = DSH_E2E_TOOL_CALLS.map(call => ({ type: "function_call", - id: "fc_dsh_e2e", - call_id: "call_dsh_e2e", + id: call.id, + call_id: call.callId, name: "read", - arguments: JSON.stringify({ file_path: "probe.txt" }), + arguments: JSON.stringify({ file_path: call.filePath }), status: "completed", - }; - return sse([ + })); + const events: Array> = [ { type: "response.created", response: { id: "resp_dsh_e2e_tool", status: "in_progress", output: [] } }, - { type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "", status: "in_progress" } }, - { type: "response.function_call_arguments.delta", output_index: 0, item_id: item.id, delta: item.arguments }, - { type: "response.function_call_arguments.done", output_index: 0, item_id: item.id, arguments: item.arguments }, - { type: "response.output_item.done", output_index: 0, item }, + ]; + // Interleave both calls so rc.6 must keep their indices and call ids distinct. + items.forEach((item, outputIndex) => { + events.push({ + type: "response.output_item.added", + output_index: outputIndex, + item: { ...item, arguments: "", status: "in_progress" }, + }); + }); + items.forEach((item, outputIndex) => { + events.push({ + type: "response.function_call_arguments.delta", + output_index: outputIndex, + item_id: item.id, + delta: item.arguments, + }); + }); + items.forEach((item, outputIndex) => { + events.push({ + type: "response.function_call_arguments.done", + output_index: outputIndex, + item_id: item.id, + arguments: item.arguments, + }); + }); + items.forEach((item, outputIndex) => { + events.push({ type: "response.output_item.done", output_index: outputIndex, item }); + }); + events.push( { type: "response.completed", - response: { id: "resp_dsh_e2e_tool", status: "completed", output: [item], usage: usage() }, + response: { id: "resp_dsh_e2e_tool", status: "completed", output: items, usage: usage() }, }, - ]); + ); + return sse(events); } function textResponse(text: string, suffix: string): Response { @@ -204,6 +234,19 @@ function hasReadTool(request: UpstreamRequest): boolean { return (request.tools ?? []).some(tool => tool.name === "read" || tool.function?.name === "read"); } +/** Both results must correlate to the exact call id that requested their file. */ +export function hasExpectedDshToolOutputs(input: unknown): boolean { + if (!Array.isArray(input)) return false; + const outputs = new Map(); + for (const item of input) { + if (typeof item !== "object" || item === null || Array.isArray(item)) continue; + const record = item as { type?: unknown; call_id?: unknown; output?: unknown }; + if (record.type !== "function_call_output" || typeof record.call_id !== "string") continue; + outputs.set(record.call_id, typeof record.output === "string" ? record.output : (JSON.stringify(record.output) ?? "")); + } + return DSH_E2E_TOOL_CALLS.every(call => outputs.get(call.callId)?.includes(call.marker) === true); +} + function opencodexConfig(baseUrl: string): OcxConfig { return { port: 0, @@ -241,7 +284,9 @@ async function runCompatibilityE2E(): Promise { const dshHome = join(root, "dsh-home"); const workspace = join(root, "workspace"); for (const dir of [ocxHome, childHome, dshHome, workspace]) mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(join(workspace, "probe.txt"), "TOOL_OK\n", { mode: 0o600 }); + for (const call of DSH_E2E_TOOL_CALLS) { + writeFileSync(join(workspace, call.filePath), `${call.marker}\n`, { mode: 0o600 }); + } const previousOpenCodexHome = process.env.OPENCODEX_HOME; const requests: UpstreamRequest[] = []; @@ -262,18 +307,18 @@ async function runCompatibilityE2E(): Promise { } const request = await req.json() as UpstreamRequest; requests.push(request); - const hasToolOutput = JSON.stringify(request.input).includes("TOOL_OK"); + const hasToolOutputs = hasExpectedDshToolOutputs(request.input); if (requests.length === 1) { if (request.model !== E2E_MODEL) return new Response("unexpected model", { status: 400 }); if (request.reasoning?.effort !== "high") return new Response("unexpected reasoning effort", { status: 400 }); if (!hasReadTool(request)) return new Response("read tool missing", { status: 400 }); return toolResponse(); } - if (hasToolOutput && hasReadTool(request)) { + if (hasToolOutputs && hasReadTool(request)) { return finalResponse(); } // rc.6 performs one no-tools title request alongside the two Agent data steps. - if (!hasToolOutput && !hasReadTool(request)) return textResponse("DSH E2E", "title"); + if (!hasToolOutputs && !hasReadTool(request)) return textResponse("DSH E2E", "title"); return new Response("unexpected DSH request shape", { status: 400 }); }, }); @@ -286,7 +331,7 @@ async function runCompatibilityE2E(): Promise { const success = await runCommand( dshBin, - ["--profile", "headless", "Read probe.txt with the read tool, then report the result."], + ["--profile", "headless", "Read probe-alpha.txt and probe-beta.txt with the read tool, then report both results."], { cwd: workspace, env: childEnv, timeoutMs: RUN_TIMEOUT_MS }, ); if (success.exitCode !== 0) throw new Error("DSH success scenario exited non-zero"); @@ -310,7 +355,7 @@ async function runCompatibilityE2E(): Promise { } if (requests.length !== beforeUnknown) throw new Error("DSH unknown-model scenario reached the upstream server"); - console.log(`DSH ${DSH_RC6_VERSION} compatibility E2E passed: tool continuation and unknown-model refusal`); + console.log(`DSH ${DSH_RC6_VERSION} compatibility E2E passed: parallel tool continuation and unknown-model refusal`); } finally { if (proxy) await proxy.stop(true); upstream?.stop(true); diff --git a/src/integrations/writer-lock.ts b/src/integrations/writer-lock.ts index adf2e4091..c304fe295 100644 --- a/src/integrations/writer-lock.ts +++ b/src/integrations/writer-lock.ts @@ -33,7 +33,8 @@ export class IntegrationWriterLockIOError extends Error { const defaultSeams: IntegrationWriterLockSeams = { writeFile: async (path, payload, options) => { await writeFile(path, payload, options); }, - removeFile: async path => { await rm(path); }, + // Match DSH rc.6: an already-absent lock is a successful release. + removeFile: async path => { await rm(path, { force: true }); }, now: () => Date.now(), delay: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), pid: process.pid, @@ -76,13 +77,19 @@ export async function withIntegrationWriterLock( } } + let outcome: { ok: true; value: T } | { ok: false; error: unknown }; try { - return await operation(); - } finally { - try { - await seams.removeFile(lockPath); - } catch (error) { - throw new IntegrationWriterLockIOError(lockPath, "release", error); - } + outcome = { ok: true, value: await operation() }; + } catch (error) { + outcome = { ok: false, error }; + } + try { + await seams.removeFile(lockPath); + } catch (error) { + // Cleanup cannot replace the protected operation's actual failure. + if (!outcome.ok) throw outcome.error; + throw new IntegrationWriterLockIOError(lockPath, "release", error); } + if (!outcome.ok) throw outcome.error; + return outcome.value; } diff --git a/tests/dsh-path-contract.test.ts b/tests/dsh-path-contract.test.ts index f6125649f..546a33233 100644 --- a/tests/dsh-path-contract.test.ts +++ b/tests/dsh-path-contract.test.ts @@ -58,6 +58,8 @@ describe("DSH 0.1.0-rc.6 path contract", () => { expect(parsed).toHaveProperty("llm-pi-ai.providers.acme-gateway.api", "openai-completions"); expect(parsed).toHaveProperty("llm-pi-ai.providers.acme-gateway.models.1.reasoningEfforts.max", "ultra"); expect(text).not.toContain("Bearer "); - expect(text).not.toContain("api_key"); + // Env references are allowed (`apiKeyEnv`); literal credential fields are not. + expect(text).not.toMatch(/^\s*api[-_]?key\s*:/imu); + expect(text).not.toMatch(/\bsk-[a-z0-9]/iu); }); }); diff --git a/tests/dsh-rc6-compat-script.test.ts b/tests/dsh-rc6-compat-script.test.ts index d25df775c..61fbe6d9d 100644 --- a/tests/dsh-rc6-compat-script.test.ts +++ b/tests/dsh-rc6-compat-script.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { + DSH_E2E_TOOL_CALLS, DSH_RC6_VERSION, assertExpectedDshVersion, buildDshChildEnv, + hasExpectedDshToolOutputs, renderDshSettings, } from "../scripts/dsh-rc6-compat-e2e"; @@ -64,4 +66,19 @@ describe("optional DSH rc.6 compatibility E2E helpers", () => { expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.DEEPSEEK_API_KEY).toBeUndefined(); }); + + test("requires two distinct parallel tool outputs correlated by call id", () => { + expect(new Set(DSH_E2E_TOOL_CALLS.map(call => call.callId)).size).toBe(2); + const matching = DSH_E2E_TOOL_CALLS.map(call => ({ + type: "function_call_output", + call_id: call.callId, + output: `${call.marker}\n`, + })); + expect(hasExpectedDshToolOutputs(matching)).toBe(true); + expect(hasExpectedDshToolOutputs(matching.slice(0, 1))).toBe(false); + expect(hasExpectedDshToolOutputs(matching.map((item, index) => ({ + ...item, + output: matching[(index + 1) % matching.length]!.output, + })))).toBe(false); + }); }); diff --git a/tests/dsh-writer-lock.test.ts b/tests/dsh-writer-lock.test.ts index 410af773c..63c8f1cf4 100644 --- a/tests/dsh-writer-lock.test.ts +++ b/tests/dsh-writer-lock.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExportModel } from "../src/clients/config-export"; @@ -89,6 +90,19 @@ describe("DSH sibling writer lock", () => { .rejects.toThrow("boom"); expect(removes).toBe(1); }); + + test("keeps the protected operation error when release also fails", async () => { + const boom = new Error("boom"); + const seams: IntegrationWriterLockSeams = { + writeFile: async () => {}, + removeFile: async () => { throw new Error("release failed"); }, + now: () => 0, + delay: async () => {}, + pid: 1, + }; + await expect(withIntegrationWriterLock("/tmp/settings.yaml", async () => { throw boom; }, seams)) + .rejects.toBe(boom); + }); }); const MODELS: ExportModel[] = [ @@ -156,6 +170,27 @@ describe("DSH coordinated mutations", () => { expect(acquisitions).toBe(2); }); + test("a real settings.yaml.lock contender becomes the typed coordinated busy error", async () => { + const dshHome = INTEGRATION_CLIENTS.dsh.detectDir({}, home); + mkdirSync(dshHome, { recursive: true }); + const configPath = INTEGRATION_CLIENTS.dsh.configPath({}, home); + const lockPath = `${configPath}.lock`; + writeFileSync(lockPath, "1\n", { mode: 0o600 }); + let clockReads = 0; + const seams: IntegrationWriterLockSeams = { + writeFile: async (path, payload, options) => { await writeFile(path, payload, options); }, + removeFile: async path => { await rm(path); }, + now: () => clockReads++ === 0 ? 0 : 2_001, + delay: async () => {}, + pid: 123, + }; + + await expect(applyIntegrationCoordinated(writeInput(), { lockSeams: seams })) + .rejects.toBeInstanceOf(IntegrationWriterLockBusyError); + expect(readFileSync(lockPath, "utf8")).toBe("1\n"); + expect(existsSync(configPath)).toBe(false); + }); + test("freezes environment and path before awaiting lock acquisition", async () => { const first = join(root, "first-dsh-home"); const second = join(root, "second-dsh-home"); diff --git a/tests/integrations-writer.test.ts b/tests/integrations-writer.test.ts index b0671b62e..28bc42bd3 100644 --- a/tests/integrations-writer.test.ts +++ b/tests/integrations-writer.test.ts @@ -518,6 +518,11 @@ describe("DSH source preservation", () => { contextWindow: 272_000, }]; expect(applyIntegration(input({ clientId: "dsh", models: refreshedModels })).ok).toBe(true); + const refreshed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { + "llm-pi-ai": { providers: { opencodex: { models: Array<{ id: string }> } } }; + }; + expect(refreshed["llm-pi-ai"].providers.opencodex.models.map(model => model.id)) + .toContain("openai/gpt-5.6"); expect(disableIntegration(input({ clientId: "dsh", models: refreshedModels })).ok).toBe(true); expect(readFileSync(configPath, "utf8")).toBe( original.replace("# DSH user header", "# DSH user header edited"), From 8ae4909475cd2ae23274d334b20cff0e2c861d77 Mon Sep 17 00:00:00 2001 From: zhouxun Date: Fri, 14 Aug 2026 17:11:56 +0800 Subject: [PATCH 4/5] fix(dsh): enforce writer lock deadline --- src/integrations/writer-lock.ts | 7 +++++-- tests/dsh-writer-lock.test.ts | 7 +++++-- tests/management-integration-routes.test.ts | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/integrations/writer-lock.ts b/src/integrations/writer-lock.ts index c304fe295..9fc3389cf 100644 --- a/src/integrations/writer-lock.ts +++ b/src/integrations/writer-lock.ts @@ -68,10 +68,13 @@ export async function withIntegrationWriterLock( if (errorCode(error) !== "EEXIST") { throw new IntegrationWriterLockIOError(lockPath, "acquire", error); } - if (seams.now() - startedAt >= LOCK_DEADLINE_MS) { + const elapsedMs = seams.now() - startedAt; + if (elapsedMs >= LOCK_DEADLINE_MS) { throw new IntegrationWriterLockBusyError(lockPath); } - const delayMs = LOCK_DELAYS_MS[Math.min(delayIndex, LOCK_DELAYS_MS.length - 1)]!; + const backoffMs = LOCK_DELAYS_MS[Math.min(delayIndex, LOCK_DELAYS_MS.length - 1)]!; + // Keep the final retry inside the advertised two-second deadline. + const delayMs = Math.min(backoffMs, LOCK_DEADLINE_MS - elapsedMs); delayIndex += 1; await seams.delay(delayMs); } diff --git a/tests/dsh-writer-lock.test.ts b/tests/dsh-writer-lock.test.ts index 63c8f1cf4..bd7cfeb7f 100644 --- a/tests/dsh-writer-lock.test.ts +++ b/tests/dsh-writer-lock.test.ts @@ -51,16 +51,19 @@ describe("DSH sibling writer lock", () => { test("times out as typed busy without deleting a contender", async () => { let now = 0; let removes = 0; + const delays: number[] = []; const seams: IntegrationWriterLockSeams = { writeFile: async () => { throw eexist(); }, removeFile: async () => { removes += 1; }, now: () => now, - delay: async ms => { now += ms; }, + delay: async ms => { delays.push(ms); now += ms; }, pid: 7, }; await expect(withIntegrationWriterLock("/tmp/settings.yaml", async () => undefined, seams)) .rejects.toBeInstanceOf(IntegrationWriterLockBusyError); - expect(now).toBe(2_100); + expect(now).toBe(2_000); + expect(delays.at(-1)).toBe(100); + expect(Math.max(...delays)).toBe(200); expect(removes).toBe(0); }); diff --git a/tests/management-integration-routes.test.ts b/tests/management-integration-routes.test.ts index 67312d0b3..894ebb96f 100644 --- a/tests/management-integration-routes.test.ts +++ b/tests/management-integration-routes.test.ts @@ -449,7 +449,7 @@ describe("cross-process integration writer lock mapping", () => { code: "integration_mutation_busy", clientId: "dsh", }); - expect(now).toBe(2_100); + expect(now).toBe(2_000); expect(existsSync(configPath)).toBe(false); }); From dd3955c07f6755342de8c38d4d7c432517b58ff6 Mon Sep 17 00:00:00 2001 From: zhouxun Date: Fri, 14 Aug 2026 18:01:28 +0800 Subject: [PATCH 5/5] test(dsh): prove restore and drift contracts --- tests/integrations-writer.test.ts | 43 ++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/integrations-writer.test.ts b/tests/integrations-writer.test.ts index 28bc42bd3..3f2bc2c28 100644 --- a/tests/integrations-writer.test.ts +++ b/tests/integrations-writer.test.ts @@ -529,7 +529,7 @@ describe("DSH source preservation", () => { ); }); - test("an edit inside the owned DSH leaf conflicts and survives", () => { + test("an edit inside the owned DSH leaf refuses refresh and disable, preserving the edit", () => { const configPath = installDsh(); expect(applyIntegration(input({ clientId: "dsh" })).ok).toBe(true); const edited = readFileSync(configPath, "utf8").replace( @@ -537,12 +537,53 @@ describe("DSH source preservation", () => { "api: user-edited", ); writeFileSync(configPath, edited); + + const refreshed = applyIntegration(input({ + clientId: "dsh", + models: [...MODELS, { + namespaced: "openai/gpt-5.6", + provider: "openai", + id: "gpt-5.6", + contextWindow: 272_000, + }], + })); + expect(refreshed.ok).toBe(false); + if (!refreshed.ok) expect(refreshed.reason).toBe("conflict"); + expect(readFileSync(configPath, "utf8")).toBe(edited); + const disabled = disableIntegration(input({ clientId: "dsh" })); expect(disabled.ok).toBe(false); if (!disabled.ok) expect(disabled.reason).toBe("conflict"); expect(readFileSync(configPath, "utf8")).toBe(edited); }); + test("restores a disabled DSH integration to the exact applied bytes", () => { + const configPath = installDsh(); + const original = [ + "agent-default-model: deepseek-official/deepseek-chat", + "llm-pi-ai:", + " providers:", + " deepseek-official:", + " api: openai-completions # native stays", + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(applyIntegration(input({ clientId: "dsh" })).ok).toBe(true); + const applied = readFileSync(configPath, "utf8"); + expect(disableIntegration(input({ clientId: "dsh" })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(original); + + const disableOperation = store.listOperations("dsh")[0]!; + expect(disableOperation.kind).toBe("disable"); + const restored = restoreIntegration({ + ...input({ clientId: "dsh" }), + opId: disableOperation.opId, + }); + expect(restored.ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(applied); + }); + test("a sibling added below a container we created survives disable", () => { const configPath = installDsh(); writeFileSync(configPath, "agent-default-model: native\n");