From 33aaaad9dea2e4d4b575f6a77fed32fc8b83aae3 Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:31:18 +0800 Subject: [PATCH 1/5] feat(clients): connect Qoder CN and WorkBuddy desktop editions --- .../src/content/docs/guides/integrations.md | 37 +++++++ gui/src/app-routing.ts | 3 + .../client-config-clients.ts | 6 +- gui/src/components/integration-marks.ts | 3 + gui/src/i18n/de.ts | 6 ++ gui/src/i18n/en.ts | 6 ++ gui/src/i18n/fr.ts | 6 ++ gui/src/i18n/ja.ts | 6 ++ gui/src/i18n/ko.ts | 6 ++ gui/src/i18n/pt.ts | 6 ++ gui/src/i18n/ru.ts | 6 ++ gui/src/i18n/tr.ts | 6 ++ gui/src/i18n/vi.ts | 6 ++ gui/src/i18n/zh-TW.ts | 6 ++ gui/src/i18n/zh.ts | 6 ++ .../integrations/FileIntegrationPage.tsx | 6 ++ gui/src/pages/integrations/integration-api.ts | 4 + .../pages/integrations/integration-tabs.ts | 6 ++ .../pages/integrations/overview-clients.ts | 3 + gui/tests/client-config-panel.test.tsx | 7 +- gui/tests/integrations-api.test.ts | 2 +- scripts/test-layout/layout.json | 2 +- src/cli/dispatch.ts | 2 +- src/cli/export-command.ts | 2 +- src/cli/help.ts | 2 +- src/cli/integration-plan-dto.ts | 4 + src/cli/registry.ts | 2 +- src/clients/config-export.ts | 12 +++ src/clients/config-export/agent-clients.ts | 88 +++++++++++++++++ src/clients/config-export/contracts.ts | 5 + src/integrations/catalog-refresh.ts | 2 +- src/integrations/mutation-plan.ts | 7 ++ src/integrations/registry.ts | 5 + src/server/management/config-routes.ts | 4 +- src/server/management/model-routes.ts | 4 +- structure/clients/integrations.md | 5 +- structure/clients/qoder.md | 6 ++ tests/cli/cli-dispatch.test.ts | 2 +- tests/clients/agent-clients.test.ts | 97 +++++++++++++++++++ tests/clients/integrations-state.test.ts | 2 +- ...ync-client-integrations-unattended.test.ts | 2 +- .../clients/sync-client-integrations.test.ts | 4 +- .../client-config-export-new-clients.test.ts | 4 +- tests/fixtures/test-layout-expected.json | 2 +- tests/gui/integrations-invariants.test.ts | 7 +- 45 files changed, 390 insertions(+), 25 deletions(-) create mode 100644 src/clients/config-export/agent-clients.ts create mode 100644 tests/clients/agent-clients.test.ts diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index e931f91263f..50adcd6b260 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -24,6 +24,9 @@ file, and removes it again. Twenty clients work this way, each with a switch: | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | | omo (Pi / senpi) | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | | Qoder | `settings.json` in `$QODER_CONFIG_DIR`, default `~/.qoder` | JSON | new sessions | loopback placeholder | +| Qoder CN | `settings.json` in `$QODERCN_CONFIG_DIR`, default `~/.qoder-cn` | JSON | new sessions | loopback placeholder | +| WorkBuddy (CN) | `~/.workbuddy/models.json` | JSON | auto reload / new sessions | loopback placeholder | +| WorkBuddy AI | `~/.workbuddy-ai/models.json` | JSON | auto reload / new sessions | loopback placeholder | | Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder | | Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` | | Command Code | `~/.commandcode/providers.json` (on Windows with the default home: use `HOME ?? USERPROFILE` only if nonblank and Windows-absolute, otherwise `homedir()`; a separately supplied home is preserved; append `.commandcode\providers.json`) | JSON | next launch of Command Code | none — keyless loopback (`apiKey: false`) | @@ -65,6 +68,40 @@ This integration retains Pi’s loopback-only export policy and stores no real A [omo (Codex / LazyCodex) role models](#omo-codex--lazycodex-role-models). OpenCode-based omo (oh-my-opencode) keeps its own config under OpenCode; opencodex does not read or write it. +### Qoder CN and WorkBuddy editions + +In **Connect**, enable **Qoder CN**, **WorkBuddy (CN)** or **WorkBuddy AI**, then choose +an OpenCodex model in the client's custom-model picker. The proxy must stay running. +The first enable saves a snapshot; disable removes only the generated contribution, +and Undo restores the snapshot. Existing models and unrelated settings remain intact. +These are separate connections from Global Qoder and from each other. + +The same operations are available through the CLI (replace `workbuddy` with +`workbuddy-ai` or `qoder-cn` for the other editions): + +```bash +ocx integration client enable --client workbuddy +ocx export --client workbuddy --json +ocx integration client disable --client workbuddy +``` + +Qoder CN honors `QODERCN_CONFIG_DIR`. Both WorkBuddy editions honor their native +`WORKBUDDY_CONFIG_DIR`, then `CODEBUDDY_CONFIG_DIR`; unset overrides keep the regional +roots in the table. Overrides must be absolute or start with `~`. WorkBuddy's legacy +array configuration and its object-form `models` list are both supported. If an existing +`availableModels` restriction excludes OpenCodex models, remove that restriction in +WorkBuddy before enabling. Project-level settings can override user-level models. + +Connections require an unauthenticated loopback listener; no upstream credentials are +copied into these clients. WorkBuddy receives the full Chat Completions URL and the +capabilities declared by the OpenCodex catalog. Restart the client or start a new session +if its model picker has not reloaded yet. Configuration/lifecycle tests do not establish +end-to-end compatibility with every upstream model or every client release. + +Doubao Desktop and Devin currently have no verified arbitrary-provider configuration +contract in this integration. They have no enable switch; adding a local JSON file alone +would not make their model pickers consume it. + Qoder IDE / qodercli supports export and managed configuration through the Dashboard and CLI: ```bash diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 51ec534ee3c..ce6a22fab16 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -149,6 +149,9 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/kilo", "integrations/droid", "integrations/qoder", + "integrations/qoder-cn", + "integrations/workbuddy", + "integrations/workbuddy-ai", ] as const; /** diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index f33555f7b23..64703e980a8 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,11 +8,14 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"] as const; +export const CLIENTS = ["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { qoder: "api.clientConfig.clientQoder", + "qoder-cn": "integrations.tab.qoderCn", + "workbuddy": "integrations.tab.workbuddy", + "workbuddy-ai": "integrations.tab.workbuddyAi", opencode: "api.clientConfig.clientOpencode", pi: "api.clientConfig.clientPi", "gentle-shell": "integrations.tab.gentleShell", @@ -67,6 +70,7 @@ export const CLIENT_LABEL_KEYS = { */ export const CLIENT_MARKS: Partial> = { qoder: "/provider-icons/qoder.svg", + "qoder-cn": "/provider-icons/qoder.svg", opencode: "/provider-icons/opencode.svg", pi: "/provider-icons/pi.svg", omp: "/provider-icons/oh-my-pi.svg", diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index 73af7a8ddd5..b641e8c0b5e 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -66,6 +66,9 @@ export const INTEGRATION_MARKS: Record = { kilo: CLIENT_MARKS.kilo ?? null, droid: CLIENT_MARKS.droid ?? null, qoder: CLIENT_MARKS.qoder ?? null, + "qoder-cn": CLIENT_MARKS["qoder-cn"] ?? null, + workbuddy: null, + "workbuddy-ai": null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 98d183042fe..207c6146bdd 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1646,6 +1646,12 @@ export const de: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Verwaltet OpenCodex-Modelle in ~/.qoder-cn/settings.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Verwaltet OpenCodex-Modelle in ~/.workbuddy/models.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Verwaltet OpenCodex-Modelle in ~/.workbuddy-ai/models.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Verwaltet OpenCodex-Anbieter und eigene Modelle in ~/.qoder/settings.json. Erfordert Loopback-Zugriff. Andere Einstellungen bleiben erhalten; Rückgängig stellt den Snapshot wieder her.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 426323078ba..19964de97aa 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2232,6 +2232,12 @@ export const en = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Manages OpenCodex models in ~/.qoder-cn/settings.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Manages OpenCodex models in ~/.workbuddy/models.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Manages OpenCodex models in ~/.workbuddy-ai/models.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Manages OpenCodex providers and custom models in ~/.qoder/settings.json. Requires loopback access. Other settings are preserved; Undo restores the snapshot.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 30834265e5e..210cceef848 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2204,6 +2204,12 @@ export const fr: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Gère les modèles OpenCodex dans ~/.qoder-cn/settings.json. Accès local requis. Les autres réglages sont conservés ; Annuler restaure la sauvegarde.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Gère les modèles OpenCodex dans ~/.workbuddy/models.json. Accès local requis. Les autres réglages sont conservés ; Annuler restaure la sauvegarde.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Gère les modèles OpenCodex dans ~/.workbuddy-ai/models.json. Accès local requis. Les autres réglages sont conservés ; Annuler restaure la sauvegarde.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Gère les fournisseurs OpenCodex et les modèles personnalisés dans ~/.qoder/settings.json. Nécessite qu’opencodex soit joignable sur une adresse de bouclage. Les autres paramètres sont conservés ; Annuler restaure la sauvegarde.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4fd3d584898..8a3765f9431 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2099,6 +2099,12 @@ export const ja: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "~/.qoder-cn/settings.json の OpenCodex モデルを管理します。ループバック接続が必要です。他の設定を保持し、元に戻すとスナップショットを復元します。", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "~/.workbuddy/models.json の OpenCodex モデルを管理します。ループバック接続が必要です。他の設定を保持し、元に戻すとスナップショットを復元します。", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json の OpenCodex モデルを管理します。ループバック接続が必要です。他の設定を保持し、元に戻すとスナップショットを復元します。", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "~/.qoder/settings.json の OpenCodex プロバイダーとカスタムモデルを管理します。ループバック接続が必要です。他の設定は保持され、元に戻すとスナップショットが復元されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index fd340f275ea..c2b3ae4b9aa 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1690,6 +1690,12 @@ export const ko: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "~/.qoder-cn/settings.json에서 OpenCodex 모델을 관리합니다. 루프백 연결이 필요합니다. 다른 설정은 유지되며 실행 취소로 스냅샷을 복원합니다.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "~/.workbuddy/models.json에서 OpenCodex 모델을 관리합니다. 루프백 연결이 필요합니다. 다른 설정은 유지되며 실행 취소로 스냅샷을 복원합니다.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json에서 OpenCodex 모델을 관리합니다. 루프백 연결이 필요합니다. 다른 설정은 유지되며 실행 취소로 스냅샷을 복원합니다.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "~/.qoder/settings.json의 OpenCodex 공급자와 사용자 지정 모델을 관리합니다. 루프백 접근이 필요합니다. 다른 설정은 유지되며 실행 취소 시 스냅샷을 복원합니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 63e7f7e1fa7..0511575d59c 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2218,6 +2218,12 @@ export const pt: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.commandcode": "Command Code", "integrations.tab.kilo": "Kilo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Gerencia modelos OpenCodex em ~/.qoder-cn/settings.json. Requer acesso local. Outras configurações são preservadas; Desfazer restaura o snapshot.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Gerencia modelos OpenCodex em ~/.workbuddy/models.json. Requer acesso local. Outras configurações são preservadas; Desfazer restaura o snapshot.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Gerencia modelos OpenCodex em ~/.workbuddy-ai/models.json. Requer acesso local. Outras configurações são preservadas; Desfazer restaura o snapshot.", "integrations.tab.qoder": "Qoder", "integrations.tab.droid": "Factory Droid", "integrations.aside.profilesTitle": "Perfis do Aside", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 167ec189185..585a23442b1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2190,6 +2190,12 @@ export const ru: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Управляет моделями OpenCodex в ~/.qoder-cn/settings.json. Требуется loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Управляет моделями OpenCodex в ~/.workbuddy/models.json. Требуется loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Управляет моделями OpenCodex в ~/.workbuddy-ai/models.json. Требуется loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Управляет провайдерами OpenCodex и пользовательскими моделями в ~/.qoder/settings.json. Требуется доступ через loopback. Остальные настройки сохраняются; отмена восстанавливает снимок.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9ec5a5e2776..d3d47ce1b8e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2217,6 +2217,12 @@ export const tr: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "~/.qoder-cn/settings.json içindeki OpenCodex modellerini yönetir. Yerel bağlantı gerekir. Diğer ayarlar korunur; Geri Al anlık görüntüyü geri yükler.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "~/.workbuddy/models.json içindeki OpenCodex modellerini yönetir. Yerel bağlantı gerekir. Diğer ayarlar korunur; Geri Al anlık görüntüyü geri yükler.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "~/.workbuddy-ai/models.json içindeki OpenCodex modellerini yönetir. Yerel bağlantı gerekir. Diğer ayarlar korunur; Geri Al anlık görüntüyü geri yükler.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "~/.qoder/settings.json içindeki OpenCodex sağlayıcılarını ve özel modelleri yönetir. Loopback erişimi gerektirir. Diğer ayarlar korunur; Geri al, anlık görüntüyü geri yükler.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 5550712097f..3789c70d743 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2180,6 +2180,12 @@ export const vi: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "Quản lý mô hình OpenCodex trong ~/.qoder-cn/settings.json. Cần kết nối loopback. Giữ nguyên cài đặt khác; Hoàn tác khôi phục bản sao lưu.", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "Quản lý mô hình OpenCodex trong ~/.workbuddy/models.json. Cần kết nối loopback. Giữ nguyên cài đặt khác; Hoàn tác khôi phục bản sao lưu.", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "Quản lý mô hình OpenCodex trong ~/.workbuddy-ai/models.json. Cần kết nối loopback. Giữ nguyên cài đặt khác; Hoàn tác khôi phục bản sao lưu.", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "Quản lý nhà cung cấp OpenCodex và mô hình tùy chỉnh trong ~/.qoder/settings.json. Yêu cầu truy cập loopback. Giữ nguyên các cài đặt khác; Hoàn tác khôi phục bản chụp.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c9d38aaa929..a2a3b67b3e9 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3133,6 +3133,12 @@ export const zhTW: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "在 ~/.qoder-cn/settings.json 中管理 OpenCodex 模型。需要本機回環連線。保留其他設定;復原可還原快照。", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "在 ~/.workbuddy/models.json 中管理 OpenCodex 模型。需要本機回環連線。保留其他設定;復原可還原快照。", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "在 ~/.workbuddy-ai/models.json 中管理 OpenCodex 模型。需要本機回環連線。保留其他設定;復原可還原快照。", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 供應商和自訂模型。需要回環存取。保留其他設定;復原會還原快照。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b39a249717e..73bbf1033a7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1663,6 +1663,12 @@ export const zh: Record = { "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", "integrations.tab.omo": "omo", + "integrations.tab.qoderCn": "Qoder CN", + "integrations.semantics.qoderCn": "在 ~/.qoder-cn/settings.json 中管理 OpenCodex 模型。需要本机回环连接。保留其他设置;撤销可恢复快照。", + "integrations.tab.workbuddy": "WorkBuddy (CN)", + "integrations.semantics.workbuddy": "在 ~/.workbuddy/models.json 中管理 OpenCodex 模型。需要本机回环连接。保留其他设置;撤销可恢复快照。", + "integrations.tab.workbuddyAi": "WorkBuddy AI", + "integrations.semantics.workbuddyAi": "在 ~/.workbuddy-ai/models.json 中管理 OpenCodex 模型。需要本机回环连接。保留其他设置;撤销可恢复快照。", "integrations.tab.qoder": "Qoder", "api.clientConfig.clientQoder": "Qoder", "integrations.semantics.qoder": "管理 ~/.qoder/settings.json 中的 OpenCodex 提供商和自定义模型。需要回环访问。保留其他设置;撤销会恢复快照。", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 130afd3d1bf..2a3a69d1277 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -90,6 +90,9 @@ const SEMANTICS_KEY: Record = { kilo: "integrations.semantics.kilo", droid: "integrations.semantics.droid", qoder: "integrations.semantics.qoder", + "qoder-cn": "integrations.semantics.qoderCn", + "workbuddy": "integrations.semantics.workbuddy", + "workbuddy-ai": "integrations.semantics.workbuddyAi", }; const TAB_LABEL_KEY: Record = { @@ -113,6 +116,9 @@ const TAB_LABEL_KEY: Record = { kilo: "integrations.tab.kilo", droid: "integrations.tab.droid", qoder: "integrations.tab.qoder", + "qoder-cn": "integrations.tab.qoderCn", + "workbuddy": "integrations.tab.workbuddy", + "workbuddy-ai": "integrations.tab.workbuddyAi", }; type FileIntegrationPageProps = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index d72609b29bf..8d7d9721356 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -3,6 +3,9 @@ import { parseAsideProfileStatus, parseAsideProfileOutcomes, type AsideProfileOu export const FILE_INTEGRATION_CLIENTS = [ "qoder", + "qoder-cn", + "workbuddy", + "workbuddy-ai", "opencode", "pi", "omp", @@ -274,6 +277,7 @@ const MISSING_STORE_DOCUMENT = /^[\x20-\x7e]{1,64}$/; const PLAN_CHANGE_KEYS = new Set(["kind", "path"]); const PLAN_PSEUDO_PATHS = new Set(["$snapshot", "$ownership", "$journal"]); const PLAN_SCHEMA_PATHS = new Set([ + "*", // A WorkBuddy legacy document is a top-level model array. "provider.opencodex", "providers.opencodex", "models.providers.opencodex", diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 2ce517ebd84..6a934215b83 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -55,6 +55,9 @@ export const TABS: readonly TabDefinition[] = [ { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, { id: "omo", hash: "integrations/omo", labelKey: "integrations.tab.omo" }, { id: "qoder", hash: "integrations/qoder", labelKey: "integrations.tab.qoder" }, + { id: "qoder-cn", hash: "integrations/qoder-cn", labelKey: "integrations.tab.qoderCn" }, + { id: "workbuddy", hash: "integrations/workbuddy", labelKey: "integrations.tab.workbuddy" }, + { id: "workbuddy-ai", hash: "integrations/workbuddy-ai", labelKey: "integrations.tab.workbuddyAi" }, { id: "cline", hash: "integrations/cline", labelKey: "integrations.tab.cline" }, { id: "kilo", hash: "integrations/kilo", labelKey: "integrations.tab.kilo" }, { id: "droid", hash: "integrations/droid", labelKey: "integrations.tab.droid" }, @@ -81,4 +84,7 @@ export const FILE_CLIENTS = new Set([ "kilo", "droid", "qoder", + "qoder-cn", + "workbuddy", + "workbuddy-ai", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 9454a4ca046..8638624fa8e 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -161,6 +161,9 @@ const FILE_LABEL_KEY: Record = { kilo: "integrations.tab.kilo", droid: "integrations.tab.droid", qoder: "integrations.tab.qoder", + "qoder-cn": "integrations.tab.qoderCn", + "workbuddy": "integrations.tab.workbuddy", + "workbuddy-ai": "integrations.tab.workbuddyAi", }; /** 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 09a09afbfbf..dc96e5265b3 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -171,7 +171,7 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut } test("the API download surface includes DSH, MiniMax Code, Aside, Raycast and omo as clients", () => { - expect(CLIENTS).toEqual(["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); + expect(CLIENTS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); expect(CLIENT_LABEL_KEYS["gentle-shell"]).toBe("integrations.tab.gentleShell"); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); @@ -252,7 +252,7 @@ test("clients render as rows, not a switch", async () => { expect(container.querySelector(".awi-clientconfig-segmented")).toBeNull(); const names = [...container.querySelectorAll(".awi-clientconfig-name")].map(el => el.textContent); // Row order follows the registry, so a new client appears without a code change here. - expect(names?.slice(0, 2)).toEqual(["Qoder", "OpenCode"]); + expect(names?.slice(0, 5)).toEqual(["Qoder", "Qoder CN", "WorkBuddy (CN)", "WorkBuddy AI", "OpenCode"]); expect(names).toHaveLength(CLIENTS.length); // Each row states where its file goes; a Download with no destination is the // ambiguity the announcement text works to prevent. @@ -331,7 +331,8 @@ test("each client row shows its own brand mark, never a borrowed one", async () const sources = [...imgSources, ...maskSources] .filter((src): src is string => src !== null && src !== ""); expect(sources.length).toBeGreaterThan(1); - expect(new Set(sources).size).toBe(sources.length); + // Global and CN editions share Qoder's own brand asset. + expect(new Set(sources).size).toBe(sources.length - 1); // Marks are decoration: the row already names its client in text. for (const img of container.querySelectorAll("img")) { expect(img.getAttribute("alt")).toBe(""); diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 79041fa25c9..9640fff43fc 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -21,7 +21,7 @@ const originalFetch = globalThis.fetch; test("all registered export clients include Cline in file integrations", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", + "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell", ]); }); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e2ec2449dfd..a473d061f2e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -117,7 +117,7 @@ "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "cli-restart-handoff.test.ts": "cli", "restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers", - "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "local-read-response-proof.test.ts": "server", + "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "agent-clients.test.ts": "clients", "responses-skills-snapshot.test.ts": "responses", "responses-code-mode-mcp-direct.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "local-read-response-proof.test.ts": "server", "claude-desktop-system-proxy.test.ts": "claude-integration", "codex-config-drift-heal.test.ts": "codex-integration", "openai-chat-audio.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index fe9324c1fb1..41001c04aa9 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -527,7 +527,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"])); + }, ["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 91f0f3e4930..00e5e9a7523 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -169,7 +169,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep const spec = EXPORT_CLIENTS[client]; const root = await runtimeBaseUrl(deps); let built: { document: unknown; text: string }; - if (client === "raycast" || client === "droid" || client === "qoder") { + if (client === "raycast" || client === "droid" || client === "qoder" || client === "qoder-cn" || client === "workbuddy" || client === "workbuddy-ai") { // The dial address alone cannot distinguish a wildcard authenticated bind // from loopback. Let the live server resolve its admission/listener policy; // saved config can differ from the process serving this request. diff --git a/src/cli/help.ts b/src/cli/help.ts index 5f5400088cf..58d66f67175 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -93,7 +93,7 @@ Usage: ocx api-key Alias of ocx access key ocx access External API keys and endpoint information ocx api Protocol paths: vocabulary, request-path preview, and policy - ocx export --client Print a client config wired to the running proxy (20 clients) + ocx export --client Print a client config wired to the running proxy (23 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, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/integration-plan-dto.ts b/src/cli/integration-plan-dto.ts index 850de05f1e5..d1004dffce6 100644 --- a/src/cli/integration-plan-dto.ts +++ b/src/cli/integration-plan-dto.ts @@ -27,6 +27,9 @@ export const FILE_INTEGRATION_CLIENTS = [ "kilo", "droid", "qoder", + "qoder-cn", + "workbuddy", + "workbuddy-ai", "gentle-shell", ] as const; @@ -57,6 +60,7 @@ const MISSING_STORE_DOCUMENT = /^[\x20-\x7e]{1,64}$/; const PLAN_CHANGE_KEYS = new Set(["kind", "path"]); const PLAN_PSEUDO_PATHS = new Set(["$snapshot", "$ownership", "$journal"]); const PLAN_SCHEMA_PATHS = new Set([ + "*", // A WorkBuddy legacy document is a top-level model array. "provider.opencodex", "providers.opencodex", "models.providers.opencodex", diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 70290fdbe53..5981b23c21c 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -516,7 +516,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", + usage: "ocx export --client [--json] [--out ] [--force]", summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Gentle Shell) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index a66e22dd15d..dc1171ad1d4 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -85,6 +85,7 @@ import { export type { CommandCodeGeneratedConfig, CommandCodeModelEntry, CommandCodeProviderBlock }; export { buildCommandCodeClientConfig, summarizeCommandCode, buildCommandCodeContribution, commandCodeProviderRoot }; +import { agentConfigPath, buildQoderCnClientConfig, buildQoderCnContribution, buildWorkbuddyClientConfig, buildWorkbuddyContribution, summarizeWorkbuddy, workbuddyApplyRefusal } from "./config-export/agent-clients"; import { buildQoderClientConfig, summarizeQoder, buildQoderContribution, qoderConfigPath } from "./config-export/qoder"; export { buildQoderClientConfig, qoderConfigPath, qoderHomeDir, type QoderGeneratedConfig } from "./config-export/qoder"; @@ -1401,6 +1402,17 @@ export const EXPORT_CLIENTS: Record = { buildContribution: buildQoderContribution, loopbackOnly: true, }, + "qoder-cn": { + id: "qoder-cn", filename: "qoder-cn-settings.json", destination: env => agentConfigPath("qoder-cn", env), + apiKeyEnv: "", exportHint: "Qoder CN uses a non-secret loopback placeholder.", format: "json", + build: buildQoderCnClientConfig, summarize: summarizeQoder, buildContribution: buildQoderCnContribution, loopbackOnly: true, + }, + ...Object.fromEntries((["workbuddy", "workbuddy-ai"] as const).map(id => [id, { + id, filename: `${id}-models.json`, destination: (env: NodeJS.ProcessEnv) => agentConfigPath(id, env), + apiKeyEnv: "", exportHint: "WorkBuddy uses a non-secret loopback placeholder.", format: "json" as const, + build: buildWorkbuddyClientConfig, summarize: summarizeWorkbuddy, validateApply: workbuddyApplyRefusal, + buildContribution: (ctx: ExportContext) => buildWorkbuddyContribution(id, ctx), loopbackOnly: true, + }])) as Record<"workbuddy" | "workbuddy-ai", ExportClientSpec>, opencode: { id: "opencode", filename: "opencode.json", diff --git a/src/clients/config-export/agent-clients.ts b/src/clients/config-export/agent-clients.ts new file mode 100644 index 00000000000..f832ccb567a --- /dev/null +++ b/src/clients/config-export/agent-clients.ts @@ -0,0 +1,88 @@ +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; +import { ClientPathError } from "../config-export"; +import { parseSegment } from "../../integrations/merge"; +import type { ExportContext, ExportClientId, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; +import { LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { normalizeExportModels, authoritativeContextWindow } from "./model-metadata"; +import { buildQoderClientConfig, buildQoderContribution } from "./qoder"; + +export function agentHomeDir(client: "qoder-cn" | "workbuddy" | "workbuddy-ai", env: OpencodeLaunchEnv = process.env, home = homedir()): string { + const key = client === "qoder-cn" ? "QODERCN_CONFIG_DIR" : "WORKBUDDY_CONFIG_DIR"; + // Both WorkBuddy editions use the same native override; the default roots stay separate. + const override = env[key]?.trim() || (client === "qoder-cn" ? undefined : env.CODEBUDDY_CONFIG_DIR?.trim()); + if (!override) return join(home, `.${client}`); + if (override === "~") return home; + if (override.startsWith("~/") || override.startsWith("~\\")) return join(home, override.slice(2)); + if (!isAbsolute(override)) throw new ClientPathError(`${key} must be an absolute path or start with ~.`); + return override; +} + +export function agentConfigPath(client: "qoder-cn" | "workbuddy" | "workbuddy-ai", env: OpencodeLaunchEnv = process.env, home = homedir()): string { + return join(agentHomeDir(client, env, home), client === "qoder-cn" ? "settings.json" : "models.json"); +} + +export function buildQoderCnClientConfig(ctx: ExportContext) { + return buildQoderClientConfig(ctx); +} +export function buildQoderCnContribution(ctx: ExportContext): ManagedContribution { + return { ...buildQoderContribution(ctx), clientId: "qoder-cn" }; +} + +function modelSelector(id: string): string { + const selector = `[id=${id}]`; + const parsed = parseSegment(selector); + if (parsed.kind !== "select" || parsed.criteria[0]?.value !== id) { + throw new Error("WorkBuddy model ID cannot be represented by a managed array selector."); + } + return selector; +} + +function workbuddyRows(ctx: ExportContext) { + return normalizeExportModels(ctx.models).map(model => { + modelSelector(model.namespaced); + return { + id: model.namespaced, + name: `OpenCodex: ${model.displayName ?? model.namespaced}`, + vendor: "OpenAI", + url: `${ctx.baseUrl.replace(/\/+$/, "")}/chat/completions`, + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + ...(authoritativeContextWindow(model.maxInputTokens) ? { maxInputTokens: authoritativeContextWindow(model.maxInputTokens) } : {}), + ...(authoritativeContextWindow(model.maxTokens) ? { maxOutputTokens: authoritativeContextWindow(model.maxTokens) } : {}), + ...(model.supportsTools === undefined ? {} : { supportsToolCall: model.supportsTools }), + ...(model.inputModalities === undefined ? {} : { supportsImages: model.inputModalities.includes("image") }), + ...(model.supportsReasoning === undefined ? {} : { supportsReasoning: model.supportsReasoning }), + }; + }); +} + +export function buildWorkbuddyClientConfig(ctx: ExportContext) { + if (shouldInjectApiAuthHeader(ctx.config) || !isLoopbackHostname(new URL(ctx.baseUrl).hostname)) { + throw new Error("WorkBuddy's placeholder API key requires a loopback-only listener."); + } + return { models: workbuddyRows(ctx) }; +} + +export function summarizeWorkbuddy(document: unknown) { + const rows = Array.isArray(document) ? document : (document as { models?: unknown[] } | undefined)?.models ?? []; + return { modelCount: rows.length, modelsWithoutLimits: rows.filter(row => !(row as { maxInputTokens?: number }).maxInputTokens).length }; +} + +/** Write-time validation must not prevent status, disable or snapshot recovery. */ +export function workbuddyApplyRefusal(ctx: ExportContext): string | null { + const models = workbuddyRows(ctx); + if (models.length === 0) return "WorkBuddy has no models in the selected OpenCodex catalog."; + const available = (ctx.document as { availableModels?: unknown } | undefined)?.availableModels; + if (Array.isArray(available) && available.length > 0 && models.some(model => !available.includes(model.id) && !available.includes(`custom-local:${model.id}`))) { + return "WorkBuddy availableModels hides OpenCodex models; remove the restriction in WorkBuddy before enabling this connection."; + } + return null; +} + +export function buildWorkbuddyContribution(clientId: ExportClientId, ctx: ExportContext): ManagedContribution { + const models = workbuddyRows(ctx); + return { clientId, fragments: models.map(model => ({ + path: [...Array.isArray(ctx.document) ? [] : ["models"], modelSelector(model.id)], value: model, + })) }; +} diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 92337f2f194..dd173910e72 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -203,6 +203,9 @@ export type DroidReasoningDefaults = Record; export type ExportClientId = | "qoder" + | "qoder-cn" + | "workbuddy" + | "workbuddy-ai" | "opencode" | "pi" | "omp" @@ -262,6 +265,8 @@ export interface ExportClientSpec { * reasoning as the Grok managed block's non-loopback refusal. */ loopbackOnly: boolean; + /** Refuse apply/refresh without blocking status, disable or Undo. Pure and value-free. */ + validateApply?: (ctx: ExportContext) => string | null; /** * True when the destination file may carry comments and trailing commas * even though `format` is "json" and serialization stays pretty JSON. diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 3c0ddcbc32d..6b91856eb1a 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -12,7 +12,7 @@ import { /** Refresh only previously connected clients; a refused file never blocks its peers. */ export async function refreshOwnedCatalogIntegrations( input: Omit, - clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "gentle-shell"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "gentle-shell"], options: { refreshOnly?: boolean; admit?: () => boolean } = {}, ): Promise { // Client files are shared with the live proxy a sibling instance runs beside; their entries diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 6a287fe9b78..4d68995f34f 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -132,6 +132,9 @@ export const DYNAMIC_SEGMENT = "*"; */ const CLIENT_MANAGED_PATHS = { qoder: [["providers", OPENCODE_PROVIDER_ID], ["modelConfigs", "customModels", DYNAMIC_SEGMENT]], + "qoder-cn": [["providers", OPENCODE_PROVIDER_ID], ["modelConfigs", "customModels", DYNAMIC_SEGMENT]], + workbuddy: [["models", DYNAMIC_SEGMENT], [DYNAMIC_SEGMENT]], + "workbuddy-ai": [["models", DYNAMIC_SEGMENT], [DYNAMIC_SEGMENT]], opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]], pi: [["providers", OPENCODE_PROVIDER_ID]], "gentle-shell": [["providers", OPENCODE_PROVIDER_ID]], @@ -1002,6 +1005,10 @@ export function observeIntegration( const record = stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null; + if (operation !== "disable") { + const refusal = exportSpec.validateApply?.({ ...exportContextOf(input), document: parsed }); + if (refusal) return { failed: observationFailure("unsafe", "unsafe", refusal) } as const; + } const contribution = buildIntegrationContribution(input, effective, parsed, record); if (clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && !record) { return { failed: observationFailure("unsafe", "unsafe", "Factory Droid has no addressable models in the selected catalog") } as const; diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index b53228fdaa5..4218e6e98b2 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -8,6 +8,7 @@ * * Design of record: devlog/_fin/260802_client_toggle_api/021 §1. */ +import { agentConfigPath, agentHomeDir } from "../clients/config-export/agent-clients"; import { homedir } from "node:os"; import { gentleShellPaths } from "../clients/gentle-shell-path"; import { assertDroidSettingsUnambiguous } from "./droid-settings"; @@ -288,6 +289,10 @@ export const INTEGRATION_CLIENTS: Record qoderHomeDir(env, home), }, + ...Object.fromEntries((["qoder-cn", "workbuddy", "workbuddy-ai"] as const).map(id => [id, { + id, configPath: (env = process.env, home = homedir()) => agentConfigPath(id, env, home), + detectDir: (env = process.env, home = homedir()) => agentHomeDir(id, env, home), + }])) as Record<"qoder-cn" | "workbuddy" | "workbuddy-ai", IntegrationClientSpec>, opencode: { id: "opencode", // These take `home` explicitly. The export registry's `destination` reads diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 91d51e128fd..9ba4d58d45e 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -331,8 +331,8 @@ export async function syncEnabledClientIntegrations( config, port, }, unattended - ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"] - : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"], + ? ["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"] + : ["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"], ...unattended ? [{ refreshOnly: true, admit }] as const : [])); return out; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 2b6586c9a94..515f524ce1a 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -561,9 +561,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { expect(code).toBe(0); expect(order).toEqual(["catalog", "refresh"]); expect(refresh).toHaveBeenCalledTimes(1); - expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]); + expect(refresh.mock.calls[0]![1]).toEqual(["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]); } finally { sync.mockRestore(); refresh.mockRestore(); aside.mockRestore(); log.mockRestore(); } diff --git a/tests/clients/agent-clients.test.ts b/tests/clients/agent-clients.test.ts new file mode 100644 index 00000000000..479cd4b097d --- /dev/null +++ b/tests/clients/agent-clients.test.ts @@ -0,0 +1,97 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { buildClientConfigText, buildClientContribution, type ExportContext } from "../../src/clients/config-export"; +import { agentConfigPath, agentHomeDir, workbuddyApplyRefusal } from "../../src/clients/config-export/agent-clients"; +import { readIntegrationState } from "../../src/integrations/state"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer"; +import { canonicalSchemaPath } from "../../src/integrations/mutation-plan"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const config = { hostname: "127.0.0.1", port: 10100, defaultProvider: "mock", providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } } } as OcxConfig; +const context: ExportContext = { config, baseUrl: "http://127.0.0.1:10100/v1", models: [ + { namespaced: "mock/a", provider: "mock", id: "a", maxInputTokens: 24000, maxTokens: 8000, supportsTools: true, supportsReasoning: true, inputModalities: ["text", "image"] }, + { namespaced: "mock/b", provider: "mock", id: "b" }, +] }; +const clients = ["qoder-cn", "workbuddy", "workbuddy-ai"] as const; +const roots: string[] = []; +afterEach(() => { roots.splice(0).forEach(removeTreeWithRetry); }); + +for (const id of clients) { + test(`${id} uses an independent native path and refuses relative overrides`, () => { + const home = join(tmpdir(), "agent-path-home"); + const filename = id === "qoder-cn" ? "settings.json" : "models.json"; + const key = id === "qoder-cn" ? "QODERCN_CONFIG_DIR" : "WORKBUDDY_CONFIG_DIR"; + expect(INTEGRATION_CLIENTS[id].configPath({}, home)).toBe(join(home, `.${id}`, filename)); + expect(agentConfigPath(id, { [key]: "~/custom" }, home)).toBe(join(home, "custom", filename)); + expect(agentHomeDir(id, { [key]: "~" }, home)).toBe(home); + expect(() => agentConfigPath(id, { [key]: "relative" }, home)).toThrow(key); + if (id !== "qoder-cn") expect(agentHomeDir(id, { WORKBUDDY_CONFIG_DIR: " ", CODEBUDDY_CONFIG_DIR: "~/legacy" }, home)).toBe(join(home, "legacy")); + }); + + test(`${id} refuses authenticated or remote placeholder export`, () => { + for (const hostname of ["0.0.0.0", "::"]) expect(() => buildClientConfigText(id, { ...context, config: { ...config, hostname } })).toThrow("loopback"); + expect(() => buildClientConfigText(id, { ...context, baseUrl: "https://example.com/v1" })).toThrow("loopback"); + }); + + for (const arrayRoot of id === "qoder-cn" ? [false] : [false, true]) { + test(`${id} preserves foreign settings through apply, refresh, disable and byte-exact Undo (${arrayRoot ? "array" : "object"})`, async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-agent-client-")); roots.push(home); + const path = agentConfigPath(id, {}, home); mkdirSync(dirname(path), { recursive: true }); + const foreign = { id: "foreign/a", vendor: "Other", name: "User model", url: "http://localhost:12000/v1/chat/completions" }; + const original = id === "qoder-cn" ? { theme: "dark", modelConfigs: { customModels: [{ provider: "other", model: "mock/a" }] } } : arrayRoot ? [foreign] : { theme: "dark", models: [foreign] }; + const before = JSON.stringify(original, null, 4) + "\n"; writeFileSync(path, before); + const store = createIntegrationStateStore(join(home, "store")); + const input: IntegrationWriteInput = { clientId: id, config, port: 10100, models: context.models, home, env: {}, store }; + expect((await applyIntegration(input)).ok).toBe(true); + const opId = store.listOperations(id)[0]!.opId; + const read = () => JSON.parse(readFileSync(path, "utf8")); + const rows = (doc: any) => id === "qoder-cn" ? doc.modelConfigs.customModels : arrayRoot ? doc : doc.models; + expect(rows(read())).toHaveLength(3); + if (id !== "qoder-cn") expect(rows(read())[0]).toEqual(foreign); + expect((await refreshIntegration({ ...input, models: context.models.slice(0, 1) })).ok).toBe(true); + expect(rows(read())).toHaveLength(2); + expect((await disableIntegration(input)).ok).toBe(true); + expect(read()).toEqual(original); + expect((await restoreIntegration({ ...input, opId, confirmDrift: true })).ok).toBe(true); + expect(readFileSync(path, "utf8")).toBe(before); + }); + } +} + +test("WorkBuddy exports canonical selectors, full chat URL and declared capabilities only", () => { + const doc = buildClientConfigText("workbuddy", context).document as { models: Record[] }; + expect(doc.models[0]).toMatchObject({ id: "mock/a", name: "OpenCodex: mock/a", vendor: "OpenAI", url: `${context.baseUrl}/chat/completions`, supportsToolCall: true, supportsImages: true, supportsReasoning: true, maxInputTokens: 24000, maxOutputTokens: 8000 }); + expect(doc.models[1]).not.toHaveProperty("supportsToolCall"); + expect(doc.models[1]).not.toHaveProperty("maxInputTokens"); + expect(() => buildClientContribution("workbuddy", { ...context, models: [{ namespaced: "mock/a]", provider: "mock", id: "a]" }] })).toThrow("selector"); + expect(workbuddyApplyRefusal({ ...context, document: { availableModels: ["foreign/a"] } })).toContain("availableModels"); + for (const client of clients) for (const fragment of buildClientContribution(client, context).fragments) { + const path = canonicalSchemaPath(client, fragment.path); + expect(path).not.toBeNull(); + expect(path).not.toContain("mock/a"); + } +}); + +test("WorkBuddy visibility restrictions refuse writes without breaking status or disable", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-workbuddy-filter-")); roots.push(home); + const path = agentConfigPath("workbuddy", {}, home); mkdirSync(dirname(path), { recursive: true }); + const input: IntegrationWriteInput = { clientId: "workbuddy", config, port: 10100, models: context.models, home, env: {}, store: createIntegrationStateStore(join(home, "store")) }; + const before = JSON.stringify({ models: [], availableModels: ["foreign/a"] }); writeFileSync(path, before); + const refused = applyIntegration(input); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.message).toContain("availableModels"); + expect(readFileSync(path, "utf8")).toBe(before); + writeFileSync(path, JSON.stringify({ models: [] })); + expect(applyIntegration(input).ok).toBe(true); + const applied = JSON.parse(readFileSync(path, "utf8")); applied.availableModels = ["foreign/a"]; + writeFileSync(path, JSON.stringify(applied)); + expect(["current", "stale"]).toContain(readIntegrationState(input).state); + expect(refreshIntegration(input).ok).toBe(false); + expect(disableIntegration(input).ok).toBe(true); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ models: [], availableModels: ["foreign/a"] }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 2ccc03b4975..5dd335c2564 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -796,7 +796,7 @@ describe("installation detection is independent of config state", () => { describe("the loopback-only set is one fact, read through one seam", () => { test("identifies exactly the registered loopback-only clients", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["qoder", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); + expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/sync-client-integrations-unattended.test.ts b/tests/clients/sync-client-integrations-unattended.test.ts index dfb46107d10..eb10631c52e 100644 --- a/tests/clients/sync-client-integrations-unattended.test.ts +++ b/tests/clients/sync-client-integrations-unattended.test.ts @@ -79,7 +79,7 @@ describe("unattended native and file client admission", () => { expect(await syncEnabledClientIntegrations(12345, config, deps(fetch), unattended)).toEqual([]); expect(fetch).not.toHaveBeenCalled(); expect(writer).not.toHaveBeenCalled(); const args = refresh.mock.calls[0]!; - expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"]); + expect(args[1]).toEqual(["mcode", "pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]); expect(args[2]?.refreshOnly).toBe(true); expect(args[2]?.admit?.()).toBe(true); config.runtimeRole = "hub"; persist(); expect(args[2]?.admit?.()).toBe(false); config.unauthenticatedLoopbackListener = { enabled: true, port: 12346 }; persist(); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index f280cd2c30a..a2cd7a035ff 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -910,7 +910,7 @@ test("the direct ocx sync command refreshes owned clients including OpenCode and expect(command).toContain("refreshOwnedCatalogIntegrations"); // Command Code joined this fan-out with its integration: a sync must refresh its // provider block too, or the client keeps a stale catalog until the next apply. - expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder"]'); + expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline", "commandcode", "droid", "opencode", "kilo", "qoder", "qoder-cn", "workbuddy", "workbuddy-ai"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 62bce86ab49..33b393b40c6 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -64,7 +64,7 @@ describe("no secret reaches a client config", () => { // credential wiring is deliberately deferred from those initial generated // integrations -- omo and Gentle Shell reuse Pi's builder, which emits no headers at all. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["qoder", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); + expect(loopbackOnly).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "droid", "gentle-shell"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { @@ -85,7 +85,7 @@ describe("no secret reaches a client config", () => { test("even a non-loopback bind never serializes the key", () => { for (const id of EXPORT_CLIENT_IDS) { - if (id === "qoder") { + if (id === "qoder" || id === "qoder-cn" || id === "workbuddy" || id === "workbuddy-ai") { expect(() => buildClientConfigText(id, ctx(REMOTE))).toThrow("loopback"); continue; } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fed4bb7ebc7..643378f34ad 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -193,7 +193,7 @@ "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", "restart-replacement.test.ts": "server", - "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", + "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", "qoder-client.test.ts": "clients", "agent-clients.test.ts": "clients", "codex-account-clear-paused.test.ts": "codex-integration", "responses-skills-snapshot.test.ts": "responses", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "openai-chat-nonce-tool-call-content.test.ts": "adapters/openai", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 8a91ab971d2..f2d3830c12f 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -99,7 +99,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(20); + expect(expected).toHaveLength(23); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -252,6 +252,9 @@ describe("the journal is metadata, never a copy of the file", () => { describe("every client survives a full lifecycle", () => { /** A pre-existing user document in each client's own format. */ const SEED: Record = { + "qoder-cn": '{"theme":"dark"}\n', + workbuddy: '{"models":[{"id":"mine","vendor":"Other"}]}\n', + "workbuddy-ai": '[{"id":"mine","vendor":"Other"}]\n', qoder: '{"providers":{"mine":{"api":"http://keep-me"}},"theme":"dark"}\n', cline: '{"version":1,"modes":{},"providers":{"mine":{"settings":{"provider":"mine"},"updatedAt":"2026-01-01T00:00:00.000Z","tokenSource":"manual"}}}\n', commandcode: '{\n "provider": {\n "mine": { "name": "Keep Me", "api": "openai-completions", "baseURL": "http://keep-me/v1", "apiKey": "test-key", "models": {} }\n }\n}\n', @@ -283,6 +286,8 @@ describe("every client survives a full lifecycle", () => { /** Where the seed's user-owned entry lives when the seed is a sequence. */ const USER_ELEMENT: Partial> = { raycast: ["providers", "[id=lmstudio]"], + workbuddy: ["models", "[id=mine]"], + "workbuddy-ai": ["[id=mine]"], droid: ["customModels", "[model=local]"], }; From 436b9134c37946af6151e75fd12c8f599e402b5c Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:38:49 +0800 Subject: [PATCH 2/5] fix(clients): guard Qoder CN legacy catalog collisions --- .../src/content/docs/guides/integrations.md | 2 +- src/integrations/state.ts | 2 +- structure/clients/qoder.md | 2 +- tests/clients/qoder-client.test.ts | 70 ++++++++++--------- 4 files changed, 39 insertions(+), 37 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 46505f4f7f2..e1330a2b2d4 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -4,7 +4,7 @@ description: Connect opencodex to OpenCode, Pi, Gentle Shell, OMP, Hermes, OpenC --- The **Connect** page writes opencodex's provider block into a client's own config -file, and removes it again. Twenty clients work this way, each with a switch: +file, and removes it again. Each client below has its own switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| diff --git a/src/integrations/state.ts b/src/integrations/state.ts index e5b97cb7bff..ca491a640e8 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -135,7 +135,7 @@ export function qoderLegacyCatalogConflict( record: OwnershipRecord | null, contribution: ManagedContribution | null, ): boolean { - if (contribution?.clientId !== "qoder") return false; + if (contribution?.clientId !== "qoder" && contribution?.clientId !== "qoder-cn") return false; const owned = new Set((record?.fragmentPaths ?? []) .filter(path => Array.isArray(path) && path.every(key => typeof key === "string")) .map(path => readPath(parsed, path))); diff --git a/structure/clients/qoder.md b/structure/clients/qoder.md index 4fbbb0316c9..3198b5c0689 100644 --- a/structure/clients/qoder.md +++ b/structure/clients/qoder.md @@ -21,7 +21,7 @@ restore uses its snapshots. On refresh, older ownership records remove their pre owned custom-model rows before merging the provider-only contribution. Foreign rows remain untouched, and edits to previously owned rows remain conflicts. The legacy selector template in `src/integrations/mutation-plan.ts` stays available for previewing that removal. -An unowned `modelConfigs.customModels` row with `provider: "opencodex"` and a model the +For both `qoder` and `qoder-cn`, an unowned `modelConfigs.customModels` row with `provider: "opencodex"` and a model the provider catalog also exports would recreate that rejection, so `classifyIntegration` reports it as a `conflict` / `unowned-key` before any write; `qoderLegacyCatalogCollision` decides which rows collide, and rows an ownership record still names are left to the refresh above. diff --git a/tests/clients/qoder-client.test.ts b/tests/clients/qoder-client.test.ts index 698c37ec0d3..a44cecaba41 100644 --- a/tests/clients/qoder-client.test.ts +++ b/tests/clients/qoder-client.test.ts @@ -29,10 +29,10 @@ const context: ExportContext = { const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); -function fixture() { +function fixture(clientId: "qoder" | "qoder-cn" = "qoder") { const home = mkdtempSync(join(tmpdir(), "ocx-qoder-test-")); roots.push(home); - const path = qoderConfigPath({}, home); + const path = INTEGRATION_CLIENTS[clientId].configPath({}, home); mkdirSync(dirname(path), { recursive: true }); const original = { theme: "dark", providers: { other: { type: "custom" } }, @@ -41,7 +41,7 @@ function fixture() { const before = JSON.stringify(original, null, 4) + "\n"; writeFileSync(path, before); const input: IntegrationWriteInput = { - clientId: "qoder", config, port: 10100, models: context.models, + clientId, config, port: 10100, models: context.models, home, env: {}, store: createIntegrationStateStore(join(home, "store")), }; return { path, original, before, input, read: () => JSON.parse(readFileSync(path, "utf8")) }; @@ -225,37 +225,39 @@ describe("Qoder client integration", () => { } }); - const unowned = { provider: "opencodex", model: "mock/a", apiKey: "user-placeholder", displayName: "mine" }; + for (const clientId of ["qoder", "qoder-cn"] as const) { + const unowned = { provider: "opencodex", model: "mock/a", apiKey: "user-placeholder", displayName: "mine" }; - test.each([false, true])("an unowned opencodex row for an exported model refuses before writing (recorded=%s)", recorded => { - const f = fixture(); - if (recorded) expect(applyIntegration(f.input).ok).toBe(true); - const document = f.read(); - document.modelConfigs.customModels.push(unowned); - const before = JSON.stringify(document, null, 2); - writeFileSync(f.path, before); - const records = JSON.stringify(f.input.store!.readRecords()); - expect(readIntegrationState(f.input)).toMatchObject({ state: "conflict", reason: "unowned-key" }); - for (const operation of ["apply", "overwrite", "disable"] as const) { - expect(previewIntegration(f.input, { operation })).toMatchObject({ canApply: false, refusalReason: "conflict" }); - } - const writes = recorded - ? [refreshIntegration(f.input), applyIntegration(f.input), overwriteIntegration(f.input), disableIntegration(f.input)] - : [applyIntegration(f.input), overwriteIntegration(f.input)]; - for (const result of writes) expect(result).toMatchObject({ ok: false, reason: "conflict", message: expect.stringContaining("modelConfigs.customModels") }); - expect(readFileSync(f.path, "utf8")).toBe(before); - expect(JSON.stringify(f.input.store!.readRecords())).toBe(records); - }); + test.each([false, true])(`${clientId}: an unowned opencodex row for an exported model refuses before writing (recorded=%s)`, recorded => { + const f = fixture(clientId); + if (recorded) expect(applyIntegration(f.input).ok).toBe(true); + const document = f.read(); + document.modelConfigs.customModels.push(unowned); + const before = JSON.stringify(document, null, 2); + writeFileSync(f.path, before); + const records = JSON.stringify(f.input.store!.readRecords()); + expect(readIntegrationState(f.input)).toMatchObject({ state: "conflict", reason: "unowned-key" }); + for (const operation of ["apply", "overwrite", "disable"] as const) { + expect(previewIntegration(f.input, { operation })).toMatchObject({ canApply: false, refusalReason: "conflict" }); + } + const writes = recorded + ? [refreshIntegration(f.input), applyIntegration(f.input), overwriteIntegration(f.input), disableIntegration(f.input)] + : [applyIntegration(f.input), overwriteIntegration(f.input)]; + for (const result of writes) expect(result).toMatchObject({ ok: false, reason: "conflict", message: expect.stringContaining("modelConfigs.customModels") }); + expect(readFileSync(f.path, "utf8")).toBe(before); + expect(JSON.stringify(f.input.store!.readRecords())).toBe(records); + }); - test("opencodex rows for other models and other providers' rows for exported models do not conflict", () => { - const f = fixture(); - const document = f.read(); - document.modelConfigs.customModels.push({ ...unowned, model: "mock/z" }, { ...unowned, provider: "elsewhere" }); - writeFileSync(f.path, JSON.stringify(document)); - expect(readIntegrationState(f.input)).toMatchObject({ state: "absent" }); - expect(applyIntegration(f.input)).toMatchObject({ ok: true, state: "current" }); - expect(f.read().modelConfigs.customModels).toEqual(document.modelConfigs.customModels); - expect(disableIntegration(f.input).ok).toBe(true); - expect(f.read()).toEqual(document); - }); + test(`${clientId}: opencodex rows for other models and other providers' rows for exported models do not conflict`, () => { + const f = fixture(clientId); + const document = f.read(); + document.modelConfigs.customModels.push({ ...unowned, model: "mock/z" }, { ...unowned, provider: "elsewhere" }); + writeFileSync(f.path, JSON.stringify(document)); + expect(readIntegrationState(f.input)).toMatchObject({ state: "absent" }); + expect(applyIntegration(f.input)).toMatchObject({ ok: true, state: "current" }); + expect(f.read().modelConfigs.customModels).toEqual(document.modelConfigs.customModels); + expect(disableIntegration(f.input).ok).toBe(true); + expect(f.read()).toEqual(document); + }); + } }); From 27f67d9c08020432f1530759d9f3e8485435a97f Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:51:16 +0900 Subject: [PATCH 3/5] fix(clients): break the Qoder export import cycle and list new export clients Import ClientPathError from its leaf module in agent-clients.ts and qoder.ts so neither joins the config-export cycle, shrink the import-cycle baseline, and name Qoder CN, WorkBuddy and WorkBuddy AI in the export command summary (CodeRabbit). --- src/cli/registry.ts | 2 +- src/clients/config-export/agent-clients.ts | 2 +- src/clients/config-export/qoder.ts | 2 +- tests/fixtures/import-cycle-baseline.json | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cli/registry.ts b/src/cli/registry.ts index de7e45108ff..7e7969429ac 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -519,7 +519,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "export", usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Gentle Shell) wired to the running proxy.", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Qoder CN, WorkBuddy, WorkBuddy AI, Gentle Shell) 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/agent-clients.ts b/src/clients/config-export/agent-clients.ts index 1d4badb5283..b67efc00ce8 100644 --- a/src/clients/config-export/agent-clients.ts +++ b/src/clients/config-export/agent-clients.ts @@ -1,7 +1,7 @@ import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; -import { ClientPathError } from "../config-export"; +import { ClientPathError } from "../client-path"; import { parseSegment } from "../../integrations/merge"; import type { ExportContext, ExportClientId, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; import { LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; diff --git a/src/clients/config-export/qoder.ts b/src/clients/config-export/qoder.ts index aaccfe775a3..d814bf85291 100644 --- a/src/clients/config-export/qoder.ts +++ b/src/clients/config-export/qoder.ts @@ -1,7 +1,7 @@ import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; -import { ClientPathError } from "../config-export"; +import { ClientPathError } from "../client-path"; import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; import { LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID } from "./constants"; import { authoritativeContextWindow, normalizeExportModels } from "./model-metadata"; diff --git a/tests/fixtures/import-cycle-baseline.json b/tests/fixtures/import-cycle-baseline.json index 48711f0273f..56a07bbcbd8 100644 --- a/tests/fixtures/import-cycle-baseline.json +++ b/tests/fixtures/import-cycle-baseline.json @@ -1,7 +1,5 @@ { "cyclic": [ - "src/clients/config-export.ts", - "src/clients/config-export/qoder.ts", "src/codex/account-lifecycle.ts", "src/codex/auth-api.ts", "src/codex/auth-api/account-list.ts", From 761472ffaa91122dd79f193f50d4ca3b4866f1e2 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:59:18 +0900 Subject: [PATCH 4/5] test(clients): include Qoder CN and WorkBuddy in the export client roster CI shard 2/4 failed on the exact EXPORT_CLIENT_IDS assertion, which still listed the pre-PR roster (and miscounted it as nineteen). It now names all 23 clients. --- tests/config/client-config-export.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index fc79e1732ea..c5fd8b90653 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -850,8 +850,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the nineteen file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); + test("covers exactly the twenty-three file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["qoder", "qoder-cn", "workbuddy", "workbuddy-ai", "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "commandcode", "prime", "aside", "raycast", "omo", "cline", "kilo", "droid", "gentle-shell"]); 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); From 6d9ba4a7743f8816c245e9c80f93bd1f8dc9f9ba Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:33:40 +0900 Subject: [PATCH 5/5] test(gui): pin the new client names and WorkBuddy monogram decisions The CI gates job failed on dev's localization and mark guards: Qoder CN, WorkBuddy (CN) and WorkBuddy AI are product names kept in English (fr and zh-TW allowlists, like Qoder), and both WorkBuddy editions deliberately render the monogram until a mark is wired for each. --- gui/tests/client-marks-assets.test.ts | 5 +++-- gui/tests/fr-localization.test.ts | 4 ++++ gui/tests/integration-marks.test.ts | 5 +++-- gui/tests/locale-parity.test.ts | 4 ++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/gui/tests/client-marks-assets.test.ts b/gui/tests/client-marks-assets.test.ts index d3d0e052c0e..82264db8bf2 100644 --- a/gui/tests/client-marks-assets.test.ts +++ b/gui/tests/client-marks-assets.test.ts @@ -115,9 +115,10 @@ test("every mark's provenance is recorded in the README", () => { * key was renamed or an entry dropped in a merge, looks identical to a client * that never had one. This is the difference. */ -test("only Gentle Shell uses the deliberate monogram fallback", () => { +// The two WorkBuddy editions also take the monogram until a mark is wired for each (#7665). +test("only Gentle Shell and the WorkBuddy editions use the deliberate monogram fallback", () => { const monogram = CLIENTS.filter(clientId => CLIENT_MARKS[clientId] === undefined); - expect(monogram).toEqual(["gentle-shell"]); + expect(monogram).toEqual(["workbuddy", "workbuddy-ai", "gentle-shell"]); }); /* diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index cf616580389..a0ffa94a0ca 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -168,6 +168,10 @@ const INTENTIONAL_ENGLISH = new Set([ // Qoder is a product name, identical in every locale. "integrations.tab.qoder", "api.clientConfig.clientQoder", + // Qoder CN and the two WorkBuddy editions are product names too. + "integrations.tab.qoderCn", + "integrations.tab.workbuddy", + "integrations.tab.workbuddyAi", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "models.reasoningEffort.ultra", diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts index daed6d91142..c09b89113bd 100644 --- a/gui/tests/integration-marks.test.ts +++ b/gui/tests/integration-marks.test.ts @@ -34,11 +34,12 @@ test("every Integrations row has a mark that resolves to a committed file", () = * client was added without a mark decision, and both look like a rendering bug * from the outside. */ -test("only Gentle Shell deliberately falls back to its own monogram", () => { +// The two WorkBuddy editions also take the monogram until a mark is wired for each (#7665). +test("only Gentle Shell and the WorkBuddy editions deliberately fall back to a monogram", () => { const monogram = Object.entries(INTEGRATION_MARKS) .filter(([, src]) => src === null) .map(([id]) => id); - expect(monogram).toEqual(["gentle-shell"]); + expect(monogram).toEqual(["gentle-shell", "workbuddy", "workbuddy-ai"]); }); /* diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 6f52ac4b1b0..3a391baead6 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -175,6 +175,10 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ // Qoder is a product name, identical in every locale. "integrations.tab.qoder", "api.clientConfig.clientQoder", + // Qoder CN and the two WorkBuddy editions are product names too. + "integrations.tab.qoderCn", + "integrations.tab.workbuddy", + "integrations.tab.workbuddyAi", // Command Code is a product name, identical in every locale. "integrations.tab.commandcode", "api.clientConfig.clientCommandCode",