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
17 changes: 15 additions & 2 deletions docs-site/src/content/docs/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,21 @@ always recoverable:
and their history rows read **Backup expired**.

Disable removes only the entries opencodex recorded as its own. If your file changed
after we wrote it, the switch locks and disable refuses rather than guessing which
edits were yours.
after we wrote it, what happens depends on whether our own entries are still intact
and on the file's format. For strict-JSON configs (OpenCode, Pi), an edit **next to**
our block — adding an MCP server, a provider of your own — shows as **Update needed**:
refreshing merges around your entries and keeps them, though formatting may be
normalized. The exception is something JSON cannot rewrite exactly — a non-finite
number like `1e999`, a number a rewrite would round (a very large integer, or one
so small it collapses to zero), `-0`, or the same key written twice in one object
— 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
file is never rewritten. For the remaining formats that can carry comments
(Hermes, OpenClaw, Kimi Code, Gajae Code — YAML, JSON5 and TOML written as whole
documents), or
whenever our own entries were edited, the switch locks and disable refuses rather
than guessing which edits were yours.

## What to expect, honestly

Expand Down
20 changes: 17 additions & 3 deletions docs-site/src/content/docs/tr/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,23 @@ sahip olduğunuz durum her zaman kurtarılabilir:
expired)** yazar.

Devre dışı bırakma, yalnızca opencodex'in kendisine ait olarak kaydettiği
girdileri kaldırır. Dosyanız biz yazdıktan sonra değiştiyse, anahtar kilitlenir
ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı
bırakmayı reddeder.
girdileri kaldırır. Dosyanız biz yazdıktan sonra değiştiyse, ne olacağı kendi
girdilerimizin hâlâ bozulmamış olup olmadığına ve dosyanın biçimine bağlıdır.
Katı JSON yapılandırmalarında (OpenCode, Pi), bloğumuzun **yanında** yapılan bir
düzenleme — bir MCP sunucusu eklemek, kendinize ait bir sağlayıcı tanımlamak —
**Güncelleme gerekli (Update needed)** olarak görünür: yenileme, girdilerinizin
etrafında birleştirir ve onları korur; yalnızca biçimlendirme
normalleştirilebilir. İstisna, JSON'un birebir yeniden yazamayacağı şeylerdir —
`1e999` gibi sonlu olmayan bir sayı, yeniden yazımın yuvarlayacağı bir sayı (çok
büyük bir tam sayı ya da sıfıra çökecek kadar küçük bir sayı), `-0` veya aynı
nesnede iki kez yazılmış bir anahtar — bu durumda anahtar kilitlenir, böylece
hiçbir şey sessizce değiştirilmez veya düşürülmez. **OMP** de yanındaki
düzenlemelerden etkilenmez, ama başka bir nedenle: writer'ı yalnızca kendi
`providers.opencodex` aralığını bayt bayt yamalar, dosyanın geri kalanı hiçbir
zaman yeniden yazılmaz. Yorum taşıyabilen diğer biçimlerde (Hermes, OpenClaw,
Kimi Code, Gajae Code — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya
kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin
size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder.

## Dürüstçe ne beklenmeli?

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 @@ -37,7 +37,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil
- **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。
- 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。

停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。
停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`,或同一個物件裡重複出現的鍵——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。

## 誠實的預期

Expand Down
100 changes: 99 additions & 1 deletion src/integrations/config-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,110 @@ import type { IntegrationClientId } from "./registry";
*/
export const PARSE_FAILED = Symbol("parse-failed");

/**
* What `JSON.parse` has already discarded by the time we hold the parsed value,
* and a rewrite would therefore silently change. Both classes are invisible in
* the parsed object, which is why this scans the RAW text — same reasoning as
* the TOML inf/nan guard below.
*
* Numbers: `1e999` overflows to Infinity (a rewrite bakes in `null` — the merge
* layer's JSON clone does it even before the serializer could refuse), `1e-9999`
* underflows to `+0`, an integer literal may have been rounded (a rewrite then
* hands consumers that read JSON integers exactly — python, jq, BigInt revivers
* — a different value), and `-0` re-serializes as `0`. Only literals whose value
* actually changed are refused: `1e21`, `1e-320` or 2^54 round-trip exactly and
* stay usable.
*
* Duplicate members: `{"a":1,"a":2}` parses to a single `a`, so rewriting the
* document DELETES the earlier member. That is content loss, not the formatting
* normalization we promise, and this classifier is what makes the rewrite of a
* user-edited file reachable at all — so it fails closed here.
*
* Scanning also avoids recursing over attacker-shaped nesting depth.
*/
function jsonTextSafeToRewrite(text: string): boolean {
/** One frame per open container; a Set for objects, null for arrays. */
const containers: Array<Set<string> | null> = [];
/** The most recent string literal — the member name if a `:` follows. */
let lastString: string | null = null;
let inString = false;
let escaped = false;
let stringStart = 0;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i]!;
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === "\"") {
inString = false;
lastString = text.slice(stringStart, i + 1);
}
continue;
}
if (ch === "\"") { inString = true; stringStart = i; continue; }
if (ch === "{" || ch === "[") {
containers.push(ch === "{" ? new Set<string>() : null);
lastString = null;
continue;
}
if (ch === "}" || ch === "]") { containers.pop(); lastString = null; continue; }
if (ch === ":") {
const members = containers[containers.length - 1];
if (members && lastString !== null) {
/*
* Decoded, not raw: `"a"` and `"a"` are spellings of ONE member,
* and JSON.parse keeps only the last of them.
*/
let name: string;
try { name = JSON.parse(lastString) as string; } catch { return false; }
if (members.has(name)) return false;
members.add(name);
}
lastString = null;
continue;
}
if (ch !== "-" && (ch < "0" || ch > "9")) continue;
let end = i + 1;
while (end < text.length && /[0-9+\-.eE]/.test(text[end]!)) end += 1;
const literal = text.slice(i, end);
i = end - 1;
const value = Number(literal);
if (!Number.isFinite(value)) return false;
if (value === 0) {
/*
* `-0` (re-serializes as `0`) and underflow: `1e-9999` is a nonzero
* value the parse already flattened to `+0`. The significand alone
* decides, so genuine zero spellings (`0`, `0.0`, `0e10`) stay usable.
*/
if (literal.startsWith("-") || /[1-9]/.test(literal.split(/[eE]/)[0]!)) return false;
}
/*
* Deliberately plain digit runs only. They are the one spelling real
* consumers read with exact integer semantics (python's json yields an
* arbitrary-precision int, jq preserves big integer literals), so baking
* in the rounded double changes what those consumers extract. Decimal or
* exponent spellings of the same value (`9007199254740993e0`, `…3.0`) are
* float semantics for every consumer — they round identically before and
* after a rewrite, and shortest-round-trip stringify preserves what any
* reader can observe, so refusing them would only manufacture dead ends
* (`1e308` is not exactly representable either, yet rewrites losslessly
* for every possible reader).
*/
const digits = literal[0] === "-" ? literal.slice(1) : literal;
if (/^[0-9]{16,}$/.test(digits) && BigInt(literal) !== BigInt(value)) return false;
}
return true;
}

/** Parse a client config, tolerating absence. PARSE_FAILED on garbage. */
export function parseConfig(text: string | null, format: ConfigFormat): unknown | typeof PARSE_FAILED {
if (text === null || text.trim().length === 0) return {};
try {
switch (format) {
case "json": return JSON.parse(text);
case "json": {
const parsed = JSON.parse(text);
return jsonTextSafeToRewrite(text) ? parsed : PARSE_FAILED;
}
case "json5": return Bun.JSON5.parse(text);
case "yaml": return Bun.YAML.parse(text);
case "toml": {
Expand Down
43 changes: 37 additions & 6 deletions src/integrations/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,15 @@ function recordedFragmentFingerprint(
/**
* The two-axis rule: the recorded bytes or fragments prove nobody changed
* what we may rewrite, and the contribution hash proves our catalog has not
* moved on. OMP is the sole fragment-scoped client because its writer patches
* only `providers.opencodex`; every whole-document serializer retains the
* whole-file fingerprint guard.
* moved on. Three classes of client (revising the unconditional whole-file
* rule of devlog 260802_client_toggle_api/021 §3 for json — #1631):
* OMP is fragment-scoped because its writer patches only
* `providers.opencodex`, so the whole-file check is skipped entirely;
* strict-json clients keep the whole-file check but downgrade a drift with
* intact owned fragments to `stale`, because a rewrite there can lose only
* formatting (comments cannot parse, non-round-tripping numbers are refused
* by the serializer); every comment-capable whole-document serializer (yaml,
* json5, toml) retains the whole-file fingerprint guard as a hard conflict.
*/
export function classifyIntegration(input: {
fileText: string | null;
Expand Down Expand Up @@ -180,12 +186,37 @@ export function classifyIntegration(input: {
return { state: "conflict", reason: "unowned-key" };
}
const clientId = input.clientId ?? input.record.clientId;
if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
return { state: "conflict", reason: "foreign-edit" };
}
/*
* Checked BEFORE file-level drift: an edit INSIDE an owned fragment is a
* conflict no matter what the rest of the file looks like, so the sibling-
* edit exemption below can never mask it.
*/
if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) {
return { state: "conflict", reason: "foreign-edit" };
}
if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
/*
* The file changed since we wrote it, but every fragment we own is still
* byte-for-byte what we put there — a sibling edit, not tampering. Apply
* rewrites the WHOLE document, so for comment-capable formats (yaml,
* json5, toml) it would drop comments the user wrote next to us: fail
* closed there. Strict JSON cannot carry comments — a commented file
* never reaches this branch because parsing already failed — so the only
* possible loss is formatting normalization: everything a rewrite would
* actually change (numbers that would not round-trip, duplicate members
* a rewrite would delete) is PARSE_FAILED in parseConfig and classifies
* as unsafe long before this branch, exactly like comments. Refusing
* forever over formatting
* dead-ends the integration on the user's first own config edit (#1631).
* Report drift instead; a re-apply merges into the parsed document as it
* stands and re-owns the file. This also lets disable proceed on a
* drifted file — removal still touches only the recorded fragment paths.
*/
if (EXPORT_CLIENTS[clientId].format !== "json") {
return { state: "conflict", reason: "foreign-edit" };
}
return { state: "stale" };
}
return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
? { state: "current" }
: { state: "stale" };
Expand Down
14 changes: 11 additions & 3 deletions src/integrations/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ function preflight(input: IntegrationWriteInput) {
const before = target.before;
const parsed = parseConfig(before, exportSpec.format);
if (parsed === PARSE_FAILED) {
return { failed: refuse(clientId, "unsafe", "unsafe", `${configPath} could not be parsed`) } as const;
return { failed: refuse(clientId, "unsafe", "unsafe",
`${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, or a duplicate member)`) } as const;
}
const contribution = exportSpec.buildContribution(exportContextOf(input));
// A record proves ownership of the file it was written FOR. Matching only by
Expand Down Expand Up @@ -368,8 +369,15 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
: `${configPath} cannot be changed safely`);
}

// current | stale only: the file fingerprint still matches our record, so the
// recorded paths are exactly what we put there.
/*
* current | stale only. What makes the removal safe is the BLOCK
* fingerprint, not the file fingerprint: the classifier verified the values
* at the recorded paths are byte-for-byte what we wrote, so removing them
* cannot take a user edit with them. The file itself may have drifted — a
* json client classifies a sibling edit as stale (#1631) — which is why the
* removal runs against the document as parsed NOW, and the re-serialize is
* value-safe because non-round-tripping numbers were refused at parse time.
*/
const { doc, removed } = removeFragments(
parsed,
record!.fragmentPaths,
Expand Down
4 changes: 4 additions & 0 deletions tests/integrations-serialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ describe("serializeDocument", () => {
expect(() => serializeDocument([1, 2], "toml")).toThrow(/TOML root must be a table/);
});





test("media types are declared for every format", () => {
expect(Object.keys(FORMAT_MEDIA_TYPE).sort()).toEqual(["json", "json5", "toml", "yaml"]);
});
Expand Down
Loading
Loading