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);