From a0e794d1d0eab22d00676903a088c2644af99ba6 Mon Sep 17 00:00:00 2001 From: rrmlima Date: Mon, 7 Sep 2026 23:15:31 -0300 Subject: [PATCH 1/2] feat(integrations): support source-preserving YAML for Hermes Agent (#3989) Configure `sourcePreservingYaml: { path: ["providers", "opencodex"] }` for Hermes Agent in `INTEGRATION_CLIENTS`. Hermes Agent is a multi-provider autonomous agent whose configuration (`~/.hermes/config.yaml`) typically hosts multiple providers, vision models, and comments. Previously, Hermes was registered without source preservation, causing `classifyIntegration` to fail-closed (`state: "conflict", reason: "foreign-edit"`) whenever a user modified sibling providers or comments. In the dashboard UI, this locked the toggle and threatened a destructive full-file restore ("Replace"). By giving Hermes source-preserving YAML semantics (joining OMP and DSH): - OpenCodex mutates only `providers.opencodex` in-place. - Sibling provider configurations, user comments, and formatting remain untouched. - Unit and invariant test suites updated with dedicated regression coverage. - Public documentation in `guides/integrations.md` updated accordingly. Closes #3989 (cherry picked from commit a1fe9caeba9d980ade932cac473934178f1925a4) --- .../src/content/docs/guides/integrations.md | 10 +- src/integrations/registry.ts | 1 + tests/clients/integrations-state.test.ts | 21 +++- tests/clients/integrations-writer.test.ts | 102 ++++++++++++------ tests/gui/integrations-invariants.test.ts | 16 +-- 5 files changed, 106 insertions(+), 44 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index b475b71e20..166a848614 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -172,11 +172,11 @@ normalized. The exception is something JSON cannot rewrite exactly — a non-fin number like `1e999`, a number a rewrite would round (a very large integer, or one so small it collapses to zero), `-0`, the same key written twice in one object, or nesting deeper than 1000 levels — which locks the switch instead, so nothing is silently changed or dropped. -**OMP** is unaffected by sibling edits too, for a different reason: its writer -patches only its own `providers.opencodex` range byte-wise, so the rest of the +**OMP, DSH and Hermes** are unaffected by sibling edits too, for a different reason: their writers +patch only their own managed provider ranges byte-wise, so the rest of the file is never rewritten. For the remaining formats that can carry comments -(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — YAML, JSON5 and TOML -written as whole documents), or +(OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — JSON5 and TOML +written as whole documents, or generic YAML without source preservation), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. @@ -192,7 +192,7 @@ parse, or one whose structure we cannot reason about, still refuses. **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 and DSH are the exceptions: their YAML writers patch only `providers.opencodex` and +OMP, DSH and Hermes 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 diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index f5780f4f98..8d67ac83a1 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -190,6 +190,7 @@ export const INTEGRATION_CLIENTS: Record hermesConfigPath(env, home), detectDir: (env = process.env, home = homedir()) => hermesHomeDir(env, home), + sourcePreservingYaml: { path: ["providers", "opencodex"] }, }, openclaw: { id: "openclaw", diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 56093b3dd6..b2d4f530cc 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -699,12 +699,31 @@ describe("ownership is scoped to recorded fragments", () => { expect(result).toEqual({ state: "stale" }); }); + test("Hermes also ignores whole-file edits outside its registry-declared fragment", () => { + // Hermes declares sourcePreservingYaml: { path: ["providers", "opencodex"] }. + const contribution = { ...ownedContribution, clientId: "hermes" as const }; + const clientRecord: OwnershipRecord = { + ...record, + clientId: "hermes", + configPath: "/tmp/hermes-config.yaml", + blockFingerprint: fingerprint(canonicalContribution(contribution)), + }; + const result = classifyIntegration({ + fileText: textWithExtra, + fileIsRegular: true, + parsed: documentWithExtra, + record: clientRecord, + contribution, + }); + expect(result).toEqual({ state: "current" }); + }); + // Re-serializing a whole document in these formats would drop any comments // the user keeps next to our block, so file-level drift stays a hard // conflict for every one of them — a regression that narrowed the condition // (say, to yaml only) must fail here, not in a user's config. for (const { clientId, configPath } of [ - { clientId: "hermes" as const, configPath: "/tmp/hermes-config.yaml" }, + { clientId: "gajae" as const, configPath: "/tmp/gajae-models.yaml" }, { clientId: "openclaw" as const, configPath: "/tmp/openclaw.json5" }, { clientId: "kimi" as const, configPath: "/tmp/kimi-config.toml" }, ]) { diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index de2f164710..f2f69f4267 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -117,6 +117,14 @@ function installOpencode(): string { return configPath; } +function installGajae(): string { + const spec = INTEGRATION_CLIENTS.gajae; + 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", @@ -683,11 +691,11 @@ describe("apply", () => { }); test("yaml clients still refuse a sibling edit rather than risk user comments", () => { - const configPath = installHermes(); - expect(applyIntegration(input()).ok).toBe(true); + const configPath = installGajae(); + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); writeFileSync(configPath, `${readFileSync(configPath, "utf8")}unknown_top: added-later\n`); - const result = applyIntegration(input()); + const result = applyIntegration(input({ clientId: "gajae" })); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("conflict"); expect(readFileSync(configPath, "utf8")).toContain("unknown_top: added-later"); @@ -992,6 +1000,40 @@ describe("DSH source preservation", () => { }); }); +describe("Hermes source preservation", () => { + test("preserves defaults, providers, comments, and formatting through refresh and disable", () => { + const configPath = installHermes(); + const original = [ + "# user header", + "model:", + " default: meituan/LongCat-2.0:free", + "providers:", + " commandcode-oauth: # keep provider comment", + " models:", + " - meituan/LongCat-2.0:free", + "", + ].join("\n"); + writeFileSync(configPath, original); + + expect(applyIntegration(input({ clientId: "hermes" })).ok).toBe(true); + const applied = readFileSync(configPath, "utf8"); + expect(applied).toContain("commandcode-oauth:"); + expect(applied).toContain("opencodex:"); + expect(applied).toContain("# keep provider comment"); + expect(applied).toContain("default: meituan/LongCat-2.0:free"); + + expect(disableIntegration(input({ clientId: "hermes" })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("disables a generated Hermes config without leaving its created container", () => { + const configPath = installHermes(); + expect(applyIntegration(input({ clientId: "hermes" })).ok).toBe(true); + expect(disableIntegration(input({ clientId: "hermes" })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(""); + }); +}); + describe("restore", () => { test("undoes an apply back to the exact prior bytes", () => { const configPath = installHermes(); @@ -1017,29 +1059,29 @@ describe("restore", () => { }); test("refuses to replace post-operation edits without confirmation", () => { - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - const opId = store.listOperations("hermes")[0]!.opId; + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + const opId = store.listOperations("gajae")[0]!.opId; writeFileSync(configPath, `${readFileSync(configPath, "utf8")}# later edit\n`); - const refused = restoreIntegration({ ...input(), opId }); + const refused = restoreIntegration({ ...input({ clientId: "gajae" }), opId }); expect(refused.ok).toBe(false); if (!refused.ok) expect(refused.reason).toBe("drift_requires_confirm"); expect(readFileSync(configPath, "utf8")).toContain("# later edit"); }); test("a confirmed drift-restore keeps the replaced version recoverable", () => { - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - const opId = store.listOperations("hermes")[0]!.opId; + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + const opId = store.listOperations("gajae")[0]!.opId; writeFileSync(configPath, `${readFileSync(configPath, "utf8")}# later edit\n`); - const restored = restoreIntegration({ ...input(), opId, confirmDrift: true }); + const restored = restoreIntegration({ ...input({ clientId: "gajae" }), opId, confirmDrift: true }); expect(restored.ok).toBe(true); // The edit we replaced is in the newest snapshot, so nothing was lost. - const newest = store.listOperations("hermes")[0]!; + const newest = store.listOperations("gajae")[0]!; expect(newest.kind).toBe("restore"); const snapshot = store.readSnapshot(newest); expect(snapshot.kind).toBe("stored"); @@ -1047,14 +1089,14 @@ describe("restore", () => { }); test("refuses an operation whose snapshot was collected", () => { - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - const row = store.listOperations("hermes")[0]!; + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + const row = store.listOperations("gajae")[0]!; // Simulate GC having removed the bytes. - rmSync(join(storeRoot, "snapshots", "hermes", row.opId), { force: true }); + rmSync(join(storeRoot, "snapshots", "gajae", row.opId), { force: true }); - const result = restoreIntegration({ ...input(), opId: row.opId }); + const result = restoreIntegration({ ...input({ clientId: "gajae" }), opId: row.opId }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("snapshot_expired"); }); @@ -1073,7 +1115,7 @@ describe("nothing leaks", () => { }); test("a failed record write rolls the file back and says so", () => { - const configPath = installHermes(); + const configPath = installGajae(); const original = "providers: {}\n"; writeFileSync(configPath, original); const io: IntegrationIO = { @@ -1083,7 +1125,7 @@ describe("nothing leaks", () => { dropRecord: clientId => store.dropRecord(clientId), }; - const result = applyIntegration(input({ io })); + const result = applyIntegration(input({ clientId: "gajae", io })); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("write_failed"); @@ -1091,11 +1133,11 @@ describe("nothing leaks", () => { } // The file is back to what it was; no half-applied state survives. expect(readFileSync(configPath, "utf8")).toBe(original); - expect(store.listOperations("hermes")).toHaveLength(0); + expect(store.listOperations("gajae")).toHaveLength(0); }); test("a failed journal append rolls back and leaves no phantom row", () => { - const configPath = installHermes(); + const configPath = installGajae(); const original = "providers: {}\n"; writeFileSync(configPath, original); const io: IntegrationIO = { @@ -1105,17 +1147,17 @@ describe("nothing leaks", () => { dropRecord: clientId => store.dropRecord(clientId), }; - const result = applyIntegration(input({ io })); + const result = applyIntegration(input({ clientId: "gajae", io })); expect(result.ok).toBe(false); expect(readFileSync(configPath, "utf8")).toBe(original); // The row is written last precisely so this cannot leave one behind. - expect(store.listOperations("hermes")).toHaveLength(0); + expect(store.listOperations("gajae")).toHaveLength(0); // And the record it wrote first is gone again. - expect(store.readRecords().hermes).toBeUndefined(); + expect(store.readRecords().gajae).toBeUndefined(); }); test("when compensation itself fails, the result says residual instead of claiming a rollback", () => { - installHermes(); + installGajae(); let writes = 0; const io: IntegrationIO = { ...fileIO(), @@ -1129,10 +1171,10 @@ describe("nothing leaks", () => { putRecord: record => store.putRecord(record), dropRecord: clientId => store.dropRecord(clientId), }; - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - const result = applyIntegration(input({ io })); + const result = applyIntegration(input({ clientId: "gajae", io })); expect(result.ok).toBe(false); if (!result.ok) { expect(result.residual).toBe(true); @@ -1193,10 +1235,10 @@ describe("nothing leaks", () => { test("an empty container the user wrote survives disable", () => { // `providers: {}` is the user's line, not ours. Pruning it because it went // empty would delete something we never owned. - const configPath = installHermes(); + const configPath = installGajae(); writeFileSync(configPath, "providers: {}\n"); - expect(applyIntegration(input()).ok).toBe(true); - expect(disableIntegration(input()).ok).toBe(true); + expect(applyIntegration(input({ clientId: "gajae" })).ok).toBe(true); + expect(disableIntegration(input({ clientId: "gajae" })).ok).toBe(true); const doc = Bun.YAML.parse(readFileSync(configPath, "utf8")) as Record; expect(doc).toEqual({ providers: {} }); diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 2353104311..47b196b688 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -111,6 +111,7 @@ describe("the client registries cannot drift apart", () => { test("source preservation and cross-process locking are registry capabilities", () => { expect(INTEGRATION_CLIENTS.omp.sourcePreservingYaml?.path).toEqual(["providers", "opencodex"]); + expect(INTEGRATION_CLIENTS.hermes.sourcePreservingYaml?.path).toEqual(["providers", "opencodex"]); expect(INTEGRATION_CLIENTS.dsh.sourcePreservingYaml?.path).toEqual([ "llm-pi-ai", "providers", "opencodex", ]); @@ -649,7 +650,6 @@ describe("the base URL is composed, never interpolated", () => { ]; for (const [hostname, expected] of cases) { const configPath = installClient("hermes"); - writeFileSync(configPath, "providers: {}\n"); const result = applyIntegration({ clientId: "hermes", models: MODELS, port: 10100, config: { ...CONFIG, hostname } as OcxConfig, @@ -673,14 +673,14 @@ describe("a restore never launders a foreign edit into owned content", () => { * made the state read `current`, and disable then deleted the user's own * field as if it were ours. */ - const configPath = installClient("hermes"); + const configPath = installClient("gajae"); writeFileSync(configPath, "providers:\n mine:\n api: http://keep-me\n"); const write = { - clientId: "hermes" as const, models: MODELS, config: CONFIG, port: 10100, + clientId: "gajae" as const, models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, }; expect(applyIntegration(write).ok).toBe(true); - const applyOp = store.listOperations("hermes")[0]!.opId; + const applyOp = store.listOperations("gajae")[0]!.opId; // The user edits the file by hand, adding something of their own. const edited = `${readFileSync(configPath, "utf8")}user_field: mine\n`; @@ -688,7 +688,7 @@ describe("a restore never launders a foreign edit into owned content", () => { // Confirmed drift-restore back to the applied bytes; the edit is snapshotted. expect(restoreIntegration({ ...write, opId: applyOp, confirmDrift: true }).ok).toBe(true); - const restoreOp = store.listOperations("hermes")[0]!.opId; + const restoreOp = store.listOperations("gajae")[0]!.opId; // Undo that restore: the user's edited bytes come back. expect(restoreIntegration({ ...write, opId: restoreOp, confirmDrift: true }).ok).toBe(true); @@ -696,7 +696,7 @@ describe("a restore never launders a foreign edit into owned content", () => { // The record no longer describes these bytes, so the state is conflict… const status = readIntegrationState({ - clientId: "hermes", models: MODELS, config: CONFIG, port: 10100, + clientId: "gajae", models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, }); expect(status.state).toBe("conflict"); @@ -717,9 +717,9 @@ describe("the store's own root stays tidy", () => { * catches is a new bookkeeping file appearing without anyone deciding it * should exist. */ - writeFileSync(installClient("hermes"), "providers: {}\n"); + writeFileSync(installClient("gajae"), "providers: {}\n"); const write = { - clientId: "hermes" as const, models: MODELS, config: CONFIG, port: 10100, + clientId: "gajae" as const, models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store, }; expect(applyIntegration(write).ok).toBe(true); From 295bcf82be2d42b84039b07782d6f9a36a80eff2 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 21:47:13 +0900 Subject: [PATCH 2/2] docs(integrations): keep fr and zh-TW in step with Hermes source preservation The English guide now lists Hermes with OMP and DSH as source-preserving YAML writers; the French and Traditional Chinese pages still named it among the clients whose switch locks on sibling edits, contradicting runtime behaviour. --- docs-site/src/content/docs/fr/guides/integrations.md | 6 +++--- docs-site/src/content/docs/zh-tw/guides/integrations.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 97babedd18..9dd4e5bc2e 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -125,9 +125,9 @@ l'actualisation fusionne les changements autour de vos entrées et les conserve, comme `1e999`, un nombre qu'une réécriture arrondirait (un très grand entier ou une valeur si petite qu'elle deviendrait zéro), `-0`, une même clé écrite deux fois dans un objet ou une imbrication de plus de 1000 niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modifié ou supprimé silencieusement. -**OMP** n'est pas affecté non plus par les modifications voisines, mais pour une autre raison : son outil -d'écriture ne modifie, octet par octet, que sa propre plage `providers.opencodex` ; le reste du fichier -n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (Hermes, OpenClaw, +**OMP, DSH et Hermes** ne sont pas affectés non plus par les modifications voisines, mais pour une autre raison : leurs outils +d'écriture ne modifient, octet par octet, que leur propre plage `providers.opencodex` ; le reste du fichier +n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (OpenClaw, Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. 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 46b03df9a1..cd767e0a9b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -80,7 +80,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil - **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。 - 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。 -停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP、DSH 與 Hermes** 同樣不受旁邊編輯影響,但原因不同:它們的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 ## 誠實的預期