Skip to content
Merged
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`, 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
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
21 changes: 18 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,24 @@ 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`, aynı
nesnede iki kez yazılmış bir anahtar veya 1000 seviyeden derin iç içe geçme —
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`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。

## 誠實的預期

Expand Down
120 changes: 119 additions & 1 deletion src/integrations/config-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
import { mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
import type { ConfigFormat } from "../clients/config-export";
import { MAX_JSON_NESTING } from "./serialize";
import { atomicWriteFile } from "../config";
import type { JournalEntry } from "./journal";
import type { OwnershipRecord } from "./ownership";
Expand All @@ -23,12 +24,129 @@ 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.
*
* Depth: the same pass counts container nesting against MAX_JSON_NESTING
* (shared with the serializer, see serialize.ts). JSON.parse handles hundreds
* of thousands of levels iteratively, but the downstream merge and
* JSON.stringify recurse — a 100KB file nested 50k deep sailed through parse
* and guard, then blew up serialization with a raw RangeError after a
* multi-GB allocation spike. A hand-written scan rather than a JSON.parse
* source-access reviver on purpose: the reviver walk recurses internally, so
* its depth limit would be an unpredictable stack size instead of this
* deterministic ceiling.
*/
function jsonTextSafeToRewrite(text: string): boolean {
/**
* One frame per open container; a Set of member names for objects, null for
* arrays. Its length is the current nesting depth.
*/
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);
if (containers.length > MAX_JSON_NESTING) return false;
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": {
/*
* Scanned BEFORE parsing: the guard is text-only, and refusing first
* means a hostile document is never materialized — the depth ceiling
* would otherwise cap the rewrite only after JSON.parse had already
* built the 50k-deep object graph. The outcome is unchanged: invalid
* JSON still returns PARSE_FAILED, from the catch below.
*/
if (!jsonTextSafeToRewrite(text)) return PARSE_FAILED;
return JSON.parse(text);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case "json5": return Bun.JSON5.parse(text);
case "yaml": return Bun.YAML.parse(text);
case "toml": {
Expand Down
81 changes: 80 additions & 1 deletion src/integrations/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,89 @@ export function renderToml(document: Record<string, unknown>, prefix = ""): stri
return `${[scalars.join("\n"), tables.join("\n\n")].filter(Boolean).join("\n\n")}\n`;
}

/**
* Ceiling on container nesting for json documents, shared by the parse-time
* scanner (config-io.ts) and the serializer walk below. One constant on
* purpose: the walk must accept every document the scanner admits, or a file
* the classifier reported as recoverable would refuse at rewrite time. Real
* configs nest a handful of levels.
*/
export const MAX_JSON_NESTING = 1000;

/** Error messages carry the path to the offending value; keep them readable. */
function clampPath(path: string): string {
return path.length > 200 ? `${path.slice(0, 100)}…${path.slice(-100)}` : path;
}

/**
* JSON.stringify writes a non-finite number as `null` and -0 as `0`; any
* other finite double round-trips value-exactly (literal-level rounding is
* the parse-time scanner's concern), so those two are exactly what this walk
* refuses — refusing more turned a state the classifier had promised as
* recoverable into a permanent refusal. Documents read from disk are already
* guarded at parse time, and the writer's merge layer JSON-clones documents —
* normalizing these values — before serializing, so on the apply/disable path
* this walk is unreachable for them: it guards the direct serializers
* (preview/export builders), same posture as the YAML and TOML renderers
* above, and enforces the nesting ceiling for every json caller before the
* recursive JSON.stringify can turn depth into a RangeError.
*
* Iterative frames instead of recursion or a node stack: depth AND size of
* the document are inputs under the writer of the config file. Recursion made
* a deep file a RangeError-500; materializing every node with its path made a
* wide file allocate a large multiple of its size. Frames keep memory
* proportional to nesting depth, and path strings exist only for the
* containers on the current path plus the failing value itself.
*/
function assertJsonNumbersRoundTrip(document: unknown, rootPath: string): void {
const refuse = (value: number, path: string): never => {
throw new UnserializableValueError(Object.is(value, -0)
? `JSON cannot rewrite -0 at ${clampPath(path)} without changing it to 0`
: `JSON cannot rewrite the number at ${clampPath(path)} without changing it to null`);
};
if (typeof document === "number" && (!Number.isFinite(document) || Object.is(document, -0))) {
refuse(document, rootPath);
}
type Frame = { container: unknown; keys: string[] | null; index: number; prefix: string };
const frames: Frame[] = [];
const pushContainer = (value: unknown, prefix: string) => {
if (Array.isArray(value)) frames.push({ container: value, keys: null, index: 0, prefix });
else if (isPlainRecord(value)) frames.push({ container: value, keys: Object.keys(value), index: 0, prefix });
};
pushContainer(document, rootPath);
while (frames.length > 0) {
const frame = frames[frames.length - 1]!;
const length = frame.keys ? frame.keys.length : (frame.container as unknown[]).length;
if (frame.index >= length) { frames.pop(); continue; }
const i = frame.index;
frame.index += 1;
const child = frame.keys
? (frame.container as Record<string, unknown>)[frame.keys[i]!]
: (frame.container as unknown[])[i];
const childPath = () => frame.keys
? (frame.prefix === "$" ? frame.keys[i]! : `${frame.prefix}.${frame.keys[i]!}`)
: `${frame.prefix}[${i}]`;
if (typeof child === "number") {
if (!Number.isFinite(child) || Object.is(child, -0)) refuse(child, childPath());
continue;
}
if (typeof child === "object" && child !== null) {
if (frames.length >= MAX_JSON_NESTING) {
throw new UnserializableValueError(
`the document nests deeper than ${MAX_JSON_NESTING} levels at ${clampPath(childPath())}, which JSON serialization cannot rewrite safely`);
}
pushContainer(child, childPath());
}
}
}

/** Every serializer returns text ending in exactly one newline. */
export function serializeDocument(document: unknown, format: ConfigFormat): string {
switch (format) {
case "json": return `${JSON.stringify(document, null, 2)}\n`;
case "json": {
assertJsonNumbersRoundTrip(document, "$");
return `${JSON.stringify(document, null, 2)}\n`;
}
case "json5": return `${Bun.JSON5.stringify(document, null, 2)}\n`;
case "yaml": return renderYaml(document);
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" };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
? { state: "current" }
: { state: "stale" };
Expand Down
Loading
Loading