Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs-site/src/content/docs/fr/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include Hermes in the follow-up exception lists

After adding Hermes to the source-preserving exceptions here, the same French page at lines 139–143 still names only OMP and DSH and directs every other client to Restore; the Traditional Chinese page repeats that contradiction at line 87. Update those follow-up paragraphs as well so both translations consistently describe the Hermes behavior documented by the canonical English page.

AGENTS.md reference: docs-site/AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

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.
Expand Down
10 changes: 5 additions & 5 deletions docs-site/src/content/docs/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/zh-tw/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Name DSH's actual managed path

For readers of the Traditional Chinese guide, this now says that OMP, DSH, and Hermes all patch providers.opencodex, but DSH actually patches llm-pi-ai.providers.opencodex; the French text at lines 128–130 makes the same incorrect claim. Spell out that OMP and Hermes share the former path while DSH uses the nested path, so users do not inspect or edit the wrong configuration section.

AGENTS.md reference: docs-site/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.


## 誠實的預期

Expand Down
1 change: 1 addition & 0 deletions src/integrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
id: "hermes",
configPath: (env = process.env, home = homedir()) => hermesConfigPath(env, home),
detectDir: (env = process.env, home = homedir()) => hermesHomeDir(env, home),
sourcePreservingYaml: { path: ["providers", "opencodex"] },
},
openclaw: {
id: "openclaw",
Expand Down
21 changes: 20 additions & 1 deletion tests/clients/integrations-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]) {
Expand Down
102 changes: 72 additions & 30 deletions tests/clients/integrations-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): IntegrationWriteInput {
return {
clientId: "hermes",
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand All @@ -1017,44 +1059,44 @@ 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");
if (snapshot.kind === "stored") expect(snapshot.text).toContain("# later edit");
});

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");
});
Expand All @@ -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 = {
Expand All @@ -1083,19 +1125,19 @@ 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");
expect(result.message).toContain("rolled back");
}
// 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 = {
Expand All @@ -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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, unknown>;
expect(doc).toEqual({ providers: {} });
Expand Down
Loading
Loading