From bbe037f2a2cc8456b8ad91cada3e7088f0b6c9a8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 00:40:46 +0900 Subject: [PATCH 1/3] feat(cli): let the terminal resolve a conflict too PR #3084 gave the dashboard a way past a conflicted client config. The CLI got nothing, so `ocx integration client enable` still dead-ends on exactly the state the overwrite path exists to escape -- and it strands the user who has no browser: an SSH session, or an agent driving the proxy. Adds `--overwrite-conflict`, spelled the way `restore --confirm-drift` already is. Never assumed: without the flag a conflict is still refused, and the field is omitted from the request entirely rather than sent as false, so a proxy on an older build sees the request it has always seen. `--overwrite-conflict` with `disable` fails locally instead of being forwarded. The route answers 400 for that pair, but a usage error names the flag that is wrong where the route reply arrives as a generic failed request. Forcing a disable over a conflict deletes a block we never wrote, which is the one thing the refusal exists to prevent. Docs said the switch "locks and disable refuses rather than guessing", which is now only half true. The English guide describes Replace and the new flag, and the three translated copies of that page get the flag block so they do not contradict the source. Verification: 41 pass in tests/cli-headless-parity.test.ts, driven red twice -- dropping the flag from the request body, and neutering the disable guard. tsc clean, privacy:scan clean, skill:surface:check current, test:changed 86 pass. --- .../content/docs/fr/guides/integrations.md | 10 ++++++ .../src/content/docs/guides/integrations.md | 18 ++++++++++ .../content/docs/tr/guides/integrations.md | 10 ++++++ .../content/docs/zh-tw/guides/integrations.md | 9 +++++ src/cli/integrations.ts | 26 ++++++++++++-- tests/cli-headless-parity.test.ts | 35 +++++++++++++++++++ 6 files changed, 106 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index c7f653c885..d61ea1c2f8 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -145,6 +145,16 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +`--overwrite-conflict` est la forme terminale de **Replace** : + +```bash +ocx integration client enable --client zcode --overwrite-conflict +``` + +Comme `--confirm-drift`, il n'est jamais supposé : sans lui, un conflit reste refusé. +Il ne s'applique qu'à `enable` ; forcer un *disable* sur un conflit supprimerait un bloc +que nous n'avons jamais écrit, donc cette combinaison est rejetée. + Pour MiniMax Code, connectez une fois le fournisseur puis utilisez l’enveloppe qui vérifie la connexion : ```bash diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 8e5957b4c1..a5125911eb 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -124,6 +124,14 @@ written as whole documents), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. +That lock is no longer a dead end. A conflicted client shows **Replace** next to its +switch, on both the overview card and the client's own page. It replaces whatever +holds our settings with the block opencodex would write, and it asks first: the +dialog names the file, says what is lost, and points at the snapshot that makes it +undoable. The switch itself stays locked, because the switch cannot know which edits +you meant to keep — only you can say so. Nothing else is relaxed: a file we cannot +parse, or one whose structure we cannot reason about, still refuses. + ## What to expect, honestly **Formatting is generally not preserved.** Applying parses a config and writes it back @@ -175,6 +183,16 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +`--overwrite-conflict` is the terminal form of **Replace**: + +```bash +ocx integration client enable --client zcode --overwrite-conflict +``` + +Like `--confirm-drift`, it is never assumed — without it a conflict is still refused. +It applies only to `enable`; forcing a *disable* over a conflict would delete a block +we never wrote, so that combination is rejected. + For MiniMax Code, connect the provider once and launch through the checked wrapper: ```bash diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index e0c1df8bb6..3c5a5c126a 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -168,6 +168,16 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +`--overwrite-conflict`, **Replace** eyleminin terminal karsiligidir: + +```bash +ocx integration client enable --client zcode --overwrite-conflict +``` + +`--confirm-drift` gibi asla varsayilmaz: bayrak yazilmadan catisma yine reddedilir. +Yalnizca `enable` icin gecerlidir; bir catismanin uzerine *disable* zorlamak hic +yazmadigimiz bir blogu silecegi icin bu birlesim reddedilir. + MiniMax Code için sağlayıcıyı bir kez bağlayın ve denetimli başlatıcı üzerinden çalıştırın: ```bash 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 8a880fef06..1bcfa4214e 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -80,6 +80,15 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +`--overwrite-conflict` 是 **Replace** 的終端形式: + +```bash +ocx integration client enable --client zcode --overwrite-conflict +``` + +和 `--confirm-drift` 一樣,它永遠不會被預設:沒有這個旗標,衝突仍然會被拒絕。 +它只適用於 `enable`;對衝突強制 *disable* 會刪除我們從未寫入的區塊,因此這個組合會被拒絕。 + MiniMax Code 先連接一次 provider,再透過會檢查設定的 launcher 啟動: ```bash diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index a654bf8cac..3b417632ff 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -29,7 +29,7 @@ const GROK_USAGE = `Usage: const CLIENT_USAGE = `Usage: ocx integration client [status] [--client ] [--json] - ocx integration client --client [--json] + ocx integration client --client [--overwrite-conflict] [--json] ocx integration client history [--client ] [--json] ocx integration client restore --op [--confirm-drift] [--json]`; @@ -212,11 +212,33 @@ export async function handleClientIntegrationCommand( throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); + /* + * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the + * refusal is the default and the waiver has to be typed. + * + * Without it the dashboard could resolve a conflict and the CLI could not, + * which strands exactly the user who cannot open a browser -- an SSH session, + * or an agent driving the proxy. That dead end is the reason the overwrite + * path exists at all. + */ + const overwriteConflict = takeFlag(args, "--overwrite-conflict"); rejectArgs(args, CLIENT_USAGE); if (!client) throw new CliUsageError("--client is required", CLIENT_USAGE); + /* + * Refused here rather than forwarded. The route answers 400 for this pair, but + * a local usage error names the flag that is wrong, where the route's reply + * arrives as a generic failed request. + */ + if (overwriteConflict && action === "disable") { + throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); + } const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}`, { method: "PUT", - body: JSON.stringify({ enabled: action === "enable" }), + // Sent only when asked for, so a proxy on an older build sees the request it + // has always seen rather than an unknown field. + body: JSON.stringify(overwriteConflict + ? { enabled: true, overwriteConflict: true } + : { enabled: action === "enable" }), }, deps); printData(result, wantsJson, [String((result as Record).message ?? `${client} ${action}d.`)]); }); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 3900af2d7a..dcd05d295b 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -554,6 +554,41 @@ describe("headless GUI parity CLI", () => { ]); }); + test("enable can waive a conflict, and only when the flag is typed", async () => { + /* + * The parity this closes: the dashboard could resolve a conflict and the CLI + * could not, which strands the user who has no browser -- an SSH session, or + * an agent driving the proxy. That dead end is the reason the overwrite path + * exists, so leaving it GUI-only reproduces it for half the users. + */ + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(["enable", "--client", "hermes", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand( + ["enable", "--client", "hermes", "--overwrite-conflict", "--json"], + runtime.deps, + )).toBe(0); + expect(runtime.requests.map(row => row.body)).toEqual([ + // Absent rather than false: an older proxy sees the request it always saw. + { enabled: true }, + { enabled: true, overwriteConflict: true }, + ]); + }); + + test("a conflict waiver cannot ride along with disable", async () => { + /* + * Forcing a DISABLE over a conflict deletes a block we do not own, which is + * the one thing the refusal exists to prevent. The route answers 400; failing + * locally names the offending flag instead of surfacing a generic request + * failure, and sends nothing. + */ + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand( + ["disable", "--client", "hermes", "--overwrite-conflict", "--json"], + runtime.deps, + )).not.toBe(0); + expect(runtime.requests).toEqual([]); + }); + test("a client integration command without its required target fails instead of guessing", async () => { const runtime = fakeRuntime(); // No `--client`: picking one for the user would write a config they never named. From 7c57647b6af562d267256c85a8ad72024dd6b52d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 00:44:58 +0900 Subject: [PATCH 2/3] test(gui): pin the two things that keep the overwrite dialog readable on a phone The conflict dialog was verified at desktop width only. Measured at 390px in both themes it is fine -- 370px wide at left:10, no horizontal overflow anywhere on the page, the Replace button not clipped, and the config path inside its container -- but two of those depended on details nothing was checking. The dialog is 370px wide and a config path is one long unbroken token, so the path needs an in-word break opportunity or it overflows and the single fact the user needs (which file is about to change) goes off screen. Two things have to hold: the path renders inside a element, and that element is allowed to break. Guard one asserts the path is in a with a long realistic path. Guard two asserts the stylesheet rule, because a CSS declaration has no type or render coverage in a DOM-less suite. Verification: 41 pass across the two files. Both driven red -- rendering the path as bare text, and dropping overflow-wrap from the dialog rule. The first attempt at the CSS falsification was itself wrong: it replaced the FIRST overflow-wrap in the file, which belongs to .integration-path, and the guard stayed green. Re-run against the whole declaration it goes red, which is the only version worth keeping. --- gui/tests/integration-marks.test.ts | 16 +++++++++++++ gui/tests/integrations-surfaces.test.tsx | 29 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts index 2d8c93bd9a..b964bc4ce1 100644 --- a/gui/tests/integration-marks.test.ts +++ b/gui/tests/integration-marks.test.ts @@ -177,3 +177,19 @@ test("the three newest marks are painted the way their artwork requires", () => expect(inksOf(bodyOf("/provider-icons/gajae-code.svg")).size).toBeGreaterThan(1); expect(/]/.test(bodyOf("/provider-icons/minimax.svg"))).toBe(true); }); + +/* + * The stylesheet rule the mobile dialog depends on. + * + * A config path is one long unbroken token and the dialog is 370px wide at a 390px + * viewport, so without an in-word break opportunity the path overflows and the one + * fact the user needs -- WHICH file is about to change -- goes off screen. This is + * a CSS declaration with no type or render coverage in a DOM-less suite, so it is + * asserted as text. + */ +test("the consequence dialog lets a long path break mid-token", () => { + const css = readFileSync(join(import.meta.dir, "..", "src", "styles-integrations.css"), "utf8"); + const rule = css.match(/\.integration-consequence-body code \{[^}]*\}/); + expect(rule).not.toBeNull(); + expect(rule![0]).toMatch(/overflow-wrap:\s*anywhere/); +}); diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index 45359026d5..8a4a858a9a 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -356,6 +356,35 @@ test("a foreign edit and an unowned block get different dialog copy", async () = .toContain("A block we did not write"); }); +test("the dialog's config path can break mid-string, so it cannot overflow a phone", async () => { + /* + * The dialog is 370px wide at a 390px viewport and the path it names is a long + * unbroken token -- `/Users/jun/.zcode/v2/config.json` and worse. Without a + * break opportunity inside the word that token overflows its own container, + * which is how the one piece of information the user needs (WHICH file) ends up + * off screen. + * + * happy-dom does no layout, so measured geometry is not available here; what is + * checkable is that the path renders inside an element the stylesheet allows to + * break. Rendered geometry was measured separately at 390px in both themes + * (dialog 370px wide at left:10, code element 212px, no overflow). + */ + stateResponse = () => json(status({ + state: "conflict", + reason: "unowned-key", + configPath: "/Users/jun/Library/Application Support/SomeVendor/deeply/nested/config.json", + })); + await remountClient(); + await act(async () => { buttonByText("Replace")!.click(); }); + + const dialog = container.querySelector(".integration-consequence-dialog")!; + const code = dialog.querySelector("code"); + // A element, not bare text: `.integration-consequence-body code` is what + // carries `overflow-wrap: anywhere`. + expect(code).not.toBeNull(); + expect(code!.textContent).toBe("/Users/jun/Library/Application Support/SomeVendor/deeply/nested/config.json"); +}); + test("unsafe locks the switch instead of guessing", async () => { stateResponse = () => json(status({ state: "unsafe", reason: "unparseable" })); await mountClient(); From e719a2a6c275d22a87353fe255af3be4cc09cc73 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 00:51:29 +0900 Subject: [PATCH 3/3] test(gui): use a synthetic home in the dialog path fixture privacy:scan rejects a committed /Users// path, and the fixture I added carried a real one. The scan ran clean before that test existed, which is how it reached CI. --- gui/tests/integrations-surfaces.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index 8a4a858a9a..d392db27ff 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -359,7 +359,7 @@ test("a foreign edit and an unowned block get different dialog copy", async () = test("the dialog's config path can break mid-string, so it cannot overflow a phone", async () => { /* * The dialog is 370px wide at a 390px viewport and the path it names is a long - * unbroken token -- `/Users/jun/.zcode/v2/config.json` and worse. Without a + * unbroken token -- a real one is `~/.zcode/v2/config.json` and worse. Without a * break opportunity inside the word that token overflows its own container, * which is how the one piece of information the user needs (WHICH file) ends up * off screen. @@ -369,10 +369,12 @@ test("the dialog's config path can break mid-string, so it cannot overflow a pho * break. Rendered geometry was measured separately at 390px in both themes * (dialog 370px wide at left:10, code element 212px, no overflow). */ + // A synthetic home, not a real one: privacy:scan rejects a committed /Users//. + const longPath = "/home/dev/Library/Application Support/SomeVendor/deeply/nested/config.json"; stateResponse = () => json(status({ state: "conflict", reason: "unowned-key", - configPath: "/Users/jun/Library/Application Support/SomeVendor/deeply/nested/config.json", + configPath: longPath, })); await remountClient(); await act(async () => { buttonByText("Replace")!.click(); }); @@ -382,7 +384,7 @@ test("the dialog's config path can break mid-string, so it cannot overflow a pho // A element, not bare text: `.integration-consequence-body code` is what // carries `overflow-wrap: anywhere`. expect(code).not.toBeNull(); - expect(code!.textContent).toBe("/Users/jun/Library/Application Support/SomeVendor/deeply/nested/config.json"); + expect(code!.textContent).toBe(longPath); }); test("unsafe locks the switch instead of guessing", async () => {