Skip to content

Commit a4ff77c

Browse files
committed
Merge remote-tracking branch 'origin/dev' into codex/wt2-zero-leak-impl
2 parents 6ec6ffc + 4a0d038 commit a4ff77c

33 files changed

Lines changed: 2100 additions & 132 deletions

devlog/_plan/260802_client_toggle_api/070_wp7_i18n_docs_hardening.md

Lines changed: 182 additions & 30 deletions
Large diffs are not rendered by default.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# 080 — CI stabilization after the feature landed
2+
3+
The feature is on `origin/dev`, every macOS gate green, and Cross-platform CI
4+
red. This document is the stabilization unit: what failed, why, and what each
5+
work-phase must prove.
6+
7+
## The evidence
8+
9+
Run `30757205162` (push of `68fe94eda`, "Cross-platform CI", job `windows`):
10+
**7222 pass / 6 skip / 24 fail**. Ubuntu and macOS legs pass the same suite —
11+
the failures are platform-specific, not logic-specific.
12+
13+
A red `dev` predates this feature: run `30738272930` on `release: v2.10.0`
14+
failed the same job before any integration code existed. Attribution is
15+
therefore per-failure evidence, never "it was already red" or "it must be mine".
16+
17+
## WP-S1 — the 24 Windows failures
18+
19+
Three root causes, not twenty-four bugs.
20+
21+
### 1. Hermes does not live at `~/.hermes` on Windows (20 tests)
22+
23+
`hermesHomeDir` resolves `%LOCALAPPDATA%\hermes` on `win32`
24+
(`src/clients/config-export.ts`). `tests/integrations-writer.test.ts` created
25+
`join(home, ".hermes")` and handed the writer a `home` whose detector directory
26+
did not exist, so `applyIntegration` refused `not_installed` and every
27+
dependent assertion fell over — the whole apply/disable/restore/nothing-leaks
28+
surface.
29+
30+
The fixture now asks the registry (`spec.detectDir` / `spec.configPath`), which
31+
is what `tests/management-integration-routes.test.ts` already did. The same
32+
assumption existed twice in `tests/integrations-invariants.test.ts`; both sites
33+
now use one `installClient()` helper.
34+
35+
**This is a fixture bug, not a source bug.** The registry was right the whole
36+
time; the tests encoded a layout it never promised.
37+
38+
### 2. Three assertions spelled the separator by hand
39+
40+
```
41+
Expected: "/tmp/h/config.yaml"
42+
Received: "\tmp\h\config.yaml"
43+
```
44+
45+
`hermesConfigPath`, `kimiConfigPath` and `gajaeConfigPath` were compared to
46+
literals. The claim each test makes is *the override wins* / *this is the
47+
documented destination* — not *paths use forward slashes*. They compare against
48+
`join(...)` now, so the property holds on both platforms and a genuine
49+
destination change still fails them.
50+
51+
### 3. The CSRF test needed a GUI bundle CI does not build
52+
53+
`ci.yml` installs dependencies and runs `bun test --isolate tests`; it never
54+
runs `build:gui` first, so `gui/dist` is absent and `serveGuiFile` has no page
55+
to inject `opencodex-session-token` / `opencodex-session-csrf` into. The test
56+
read empty strings. It passed locally only because a stale build sat on disk —
57+
the same class of false confidence the WP5/WP6 audit kept finding.
58+
59+
There is no wire route to mint a GUI session without that page, and issuing one
60+
from a fresh `initializeManagementAuthState` returns a token bound to a
61+
different session map than the running server's, which would make the
62+
assertions meaningless. So the no-bundle case returns early with the absent
63+
bundle **asserted** (`existsSync(...)` is `false`, via `fileURLToPath` — a
64+
Windows URL `.pathname` is `/D:/...` and would make the guard vacuous).
65+
66+
The ordering claim the test exists for — admission runs before dispatch — stays
67+
covered on those platforms by the admin-token test directly above it, which
68+
drives the same real listener.
69+
70+
Verification for this one is local and exact: move `gui/dist` aside, re-run,
71+
22 pass / 0 fail.
72+
73+
### Not ours: the 24th failure
74+
75+
`tests/codex-prompt-adopt.test.ts` → `salvage > preview returns a directory,
76+
not a reserved filename`. `previewSalvage` computes
77+
`storePath.slice(0, storePath.lastIndexOf("/") + 1)`, which never matches a
78+
backslash path, so `backupDir` comes back `"."` on Windows and the
79+
`endsWith("/")` assertion fails.
80+
81+
That is a **real source bug on Windows**, in `src/codex/prompt-layers.ts`
82+
explicitly out of this unit's write scope and owned by another session's work
83+
(`ca087b591`, `9bb410ab3`, `d70fde4d9`). Reported, not patched: silently
84+
touching another stream's file is how two sessions start overwriting each
85+
other. Fixing it needs `dirname()` and a separator-agnostic assertion.
86+
87+
## WP-S2 — attribution
88+
89+
Inspect every job of the run that follows `7a8323c0a`, not just Windows. For
90+
each remaining failure, record whether it is feature-caused (fix it) or
91+
pre-existing (name the earlier failing run id). "Already red" is not
92+
attribution.
93+
94+
### Result
95+
96+
The earlier red run `30738272930` (`release: v2.10.0`, commit `f9b9440c5`) is
97+
**not** what the first reading of it suggested. Its failing job was **ubuntu**,
98+
not windows — windows and macos both passed there — and the job died at 3m47s
99+
with no test summary in the log at all: a crashed step, not a test failure.
100+
101+
`src/integrations` does not exist at `f9b9440c5` (`git ls-tree` returns
102+
nothing), and `f9b9440c5` is an ancestor of this work. So that failure is
103+
**pre-existing and unrelated**, established by the tree at that commit rather
104+
than by argument.
105+
106+
That also corrects an assumption in the CONTEXT above: this feature did not
107+
inherit a red Windows leg. Windows was green before the feature and the 24
108+
failures were entirely ours.
109+
110+
The CSRF failure deserves the same correction. It was reported as a Windows
111+
failure and it was not: run `30759521240` failed it on **ubuntu** too. Reading
112+
only the Windows job would have produced a Windows-shaped fix for a
113+
cross-platform cause (the missing `gui/dist`). Inspect every job, not the one
114+
that looks guilty.
115+
116+
## WP-S3 — semantic stabilization: result
117+
118+
Seven cross-phase defects, all reproduced at runtime by the reviewer. Five are
119+
fixed (`3bc89c283`, `52a9fa2bd`); three are deferred with reasons, in the
120+
order the reviewer recommended:
121+
122+
1. **OpenClaw ignores its documented path overrides.** `openclawHomeDir`
123+
returns `~/.openclaw` unconditionally, while every sibling client honors an
124+
override (`HERMES_HOME`, `KIMI_CODE_HOME`, `XDG_CONFIG_HOME`). Current
125+
OpenClaw resolves `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR` and
126+
profiles, so the toggle can report success after writing a file the running
127+
gateway never reads — and snapshot the wrong file too. Release-blocking for
128+
the OpenClaw integration specifically; the other five are unaffected.
129+
2. **Export serializers meet arbitrary user documents.** `renderYaml` and
130+
`renderToml` were written for builder output; the writer feeds them the
131+
user's whole parsed file. A YAML `null` or a TOML numeric array throws out
132+
of the writer and surfaces as a 500. Nothing is overwritten — the throw
133+
happens before commit — but a valid client config cannot use the feature.
134+
The minimum honest fix is a structured `unsafe` refusal; the real fix is
135+
serializers covering each client's valid domain.
136+
3. **Absence-result Undo disagrees with restore drift detection.** The route
137+
represents a missing file as `""` and marks such a row undoable; the writer
138+
compares `fingerprint("")` against `""` and demands drift confirmation. A
139+
shared matcher honoring `resultAbsent` belongs in both. Costs an
140+
unnecessary confirmation, preserves bytes — the least urgent of the three.
141+
142+
Each is its own work-phase, appended to the goalplan rather than folded into a
143+
stabilization commit that would hide them.
144+
145+
## WP-S3 — semantic stabilization
146+
147+
Every phase is landed now, so the contract can be read end to end for the first
148+
time: registry → writer → routes → GUI → CLI, across all six clients. Look for
149+
drift the per-phase audits could not see because the later half did not exist.
150+
151+
## WP-S4 — types and docs
152+
153+
Typecheck strictness over the feature surface, escape-hatch review, docs-site
154+
build, and the unit's own `check-drift` / `check-blocks`.
155+
156+
### Result
157+
158+
**Type safety: nothing to fix.** Across `src/integrations/**`,
159+
`src/clients/config-export.ts`, `src/server/management/integration-routes.ts`,
160+
`src/cli/integrations.ts` and `gui/src/pages/integrations/**` there is not one
161+
`any`, `@ts-ignore`, `@ts-expect-error`, or `as unknown as`. The casts that do
162+
exist are five `as Record<string, unknown>` narrowings, each on the line after
163+
the `isPlainRecord` / `typeof` check that makes it safe — the compiler cannot
164+
carry the guard across the index access, so the cast is the narrowing, not an
165+
escape from it. `tsconfig.json` is `strict: true`, and the GUI additionally
166+
enforces `erasableSyntaxOnly`, which is what caught a parameter-property in
167+
the browser adapter during WP5.
168+
169+
**Two lint suppressions, both deliberate and both explained at the site:**
170+
`react-hooks/set-state-in-effect` in `use-app-route-state.ts` (reconciles a
171+
hash changed before the listener existed; the equality check bounds it to one
172+
render) and `react-doctor/async-await-in-loop` in `IntegrationsOverview.tsx`
173+
(the bulk loop is serial on purpose — the server's single-flight guard is
174+
keyed per client and the record file is read-modify-write, so parallelising it
175+
would drop ownership records).
176+
177+
**Docs: one overpromise corrected.** The page said "every value you had is
178+
still there and equal", which the audit showed the code cannot guarantee for
179+
every input — a TOML file using `inf`/`nan` is unreadable through the parser
180+
available to us. Rather than restate the promise, the page now says what
181+
actually happens: the round trip covers the value kinds these formats use in
182+
practice, and where it does not, applying stops and names the file instead of
183+
writing a changed value. That is the honest version of the same guarantee.
184+
185+
`check-drift` clean across 21 docs; `check-blocks` tsc-clean across 79
186+
extracted blocks; docs-site builds 211 pages.
187+
188+
### Known limitation, carried forward
189+
190+
The reviewer's architectural point stands and is not closed by this phase: a
191+
renderer extended case by case is not the same as a serializer whose supported
192+
domain is the format's own. Each concrete gap they reproduced is fixed and
193+
refuses safely rather than corrupting, but full fidelity would need a
194+
document-preserving TOML/YAML pipeline. That is a dependency decision, not a
195+
patch, and it belongs to whoever picks up comment preservation.
196+
197+
## Rule for this unit
198+
199+
A test that cannot run on a platform is skipped with a stated specific reason.
200+
Narrowing an assertion until it passes is not a fix, and neither is deleting
201+
the platform from the matrix.

docs-site/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export default defineConfig({
9292
{ label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" },
9393
{ label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", ru: "opencode", ja: "opencode" }, slug: "guides/opencode" },
9494
{ label: "Pi", translations: { ko: "Pi", "zh-CN": "Pi", ru: "Pi", ja: "Pi" }, slug: "guides/pi" },
95+
{ label: "Integrations", translations: { ko: "연동", "zh-CN": "集成", ru: "Интеграции", ja: "連携" }, slug: "guides/integrations" },
9596
{ label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" },
9697
{ label: "Image Bridge", translations: { ko: "이미지 브릿지", "zh-CN": "图像桥接", ru: "Image Bridge", ja: "画像ブリッジ" }, slug: "guides/image-bridge" },
9798
{ label: "Video Bridge", translations: { ko: "비디오 브릿지", "zh-CN": "视频桥接", ru: "Video Bridge", ja: "動画ブリッジ" }, slug: "guides/video-bridge" },
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
title: Integrations
3+
description: Connect opencodex to OpenCode, Pi, Hermes, OpenClaw, Kimi Code and Gajae Code from the dashboard — one switch per client, with a backup taken before every write.
4+
---
5+
6+
The **Integrations** tab writes opencodex's provider block into a client's own config
7+
file, and removes it again. Six clients work this way, each with a switch:
8+
9+
| Client | Config file | Format | When the change takes effect | Credential |
10+
|---|---|---|---|---|
11+
| OpenCode | `~/.config/opencode/opencode.json` | JSON | next direct launch | `OPENCODEX_OPENCODE_API_KEY` |
12+
| Pi | `~/.pi/agent/models.json` | JSON | new sessions | `OPENCODEX_API_KEY` |
13+
| Hermes | `~/.hermes/config.yaml` | YAML | new sessions | `OPENCODEX_HERMES_API_KEY` |
14+
| OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | immediately, on a running gateway | `OPENCODEX_OPENCLAW_API_KEY` |
15+
| Kimi Code | `~/.kimi-code/config.toml` | TOML | on restart, or `/reload` | loopback placeholder |
16+
| Gajae Code | `~/.gjc/agent/models.yml` | YAML | new sessions, or when you open `/model` |`OPENCODEX_GAJAE_API_KEY` |
17+
18+
Paths honor each client's own environment override where it has one, so a relocated
19+
`HERMES_HOME`, `KIMI_CODE_HOME` or `XDG_CONFIG_HOME` is followed rather than guessed
20+
at. The table lists each client's default; an override always wins.
21+
22+
OpenClaw has several, and they do different jobs. `OPENCLAW_CONFIG_PATH` selects the
23+
file; `OPENCLAW_STATE_DIR`, `OPENCLAW_PROFILE` and `OPENCLAW_HOME` select the state
24+
directory, which is also what detection looks at — so a profile or relocated home
25+
still reads as installed, while a config-path override moves only the file. If you
26+
are still on the older `.clawdbot` layout, that is found too: the modern directory
27+
wins when it exists, and the legacy one is used when it is the only one there.
28+
29+
These must be **absolute paths** or start with `~`. A relative one is refused rather
30+
than resolved, because it would mean whatever directory each process happened to
31+
start in — and that path is stored with the backup, so it has to name the same file
32+
tomorrow as it did today.
33+
34+
opencodex reads these from its own environment. If your gateway runs with a profile
35+
or a relocated home, start opencodex with the same variables set, or it will
36+
correctly follow a different installation.
37+
38+
## The other four surfaces are not switches
39+
40+
**API Keys** manages opencodex's own credentials and is not a client at all. **Codex
41+
CLI** is wired by the proxy service itself — starting opencodex applies it, stopping it
42+
restores native routing — so there is nothing to toggle per-file. **Claude** keeps its
43+
own enable flag and Desktop's Save/Apply flow, and **Grok Build** keeps its
44+
select-then-apply model fence. Those semantics predate this feature and are unchanged.
45+
46+
## Rollback
47+
48+
Every successful write takes a snapshot of your file *first*, so the state you had is
49+
always recoverable:
50+
51+
- **Undo** appears on the newest operation when your file still matches what we wrote.
52+
- **Restore this point…** appears on older operations, or when the file changed after
53+
that operation. Restoring across such a change asks a second time before replacing
54+
your newer edits — and backs them up too, so that restore is itself undoable.
55+
- Ten backups are kept per client. Beyond that, the oldest snapshot files are removed
56+
and their history rows read **Backup expired**.
57+
58+
Disable removes only the entries opencodex recorded as its own. If your file changed
59+
after we wrote it, the switch locks and disable refuses rather than guessing which
60+
edits were yours.
61+
62+
## What to expect, honestly
63+
64+
**Formatting is not preserved.** Applying parses your config and writes it back out, so
65+
every format may be reformatted, and YAML, JSON5 and TOML additionally lose their
66+
comments. Your settings survive the round trip and the bytes change. If you need the
67+
file exactly as it was, use Restore rather than Disable: the snapshot is a verbatim
68+
copy.
69+
70+
**If a value cannot be rewritten faithfully, the switch refuses instead.** The round
71+
trip covers the value kinds these formats use in practice, and where it does not —
72+
a TOML file using `inf` or `nan`, for instance, which the parser available to us
73+
cannot read back accurately — applying stops and says so rather than writing a
74+
changed value and calling it success. You will see the file named and nothing on
75+
disk will have moved. Editing that file by hand still works; it is only our
76+
automatic rewrite that declines.
77+
78+
**Pi, Kimi Code and Gajae Code only work against a loopback bind.** None of their config
79+
schemas has a place for the `x-opencodex-api-key` header that a non-loopback bind
80+
requires, so a generated config would simply be rejected — and writing one by hand does
81+
not help, because there is nowhere in the file to put the header either. Reaching a
82+
remote opencodex from these clients is not supported directly; give them loopback access
83+
instead, through an SSH tunnel or a local forwarder that adds the header.
84+
85+
**Kimi Code cannot hold an environment reference,** so its config carries an
86+
`opencodex-loopback` placeholder rather than a key. No real credential is ever written
87+
into any client config.
88+
89+
**For `ocx opencode`, the launcher's provider block wins.** That launcher injects
90+
`provider.opencodex` through `OPENCODE_CONFIG_CONTENT`, which outranks the same entry on
91+
disk — the rest of your opencode config still applies as usual. The switch here is what
92+
matters when you launch `opencode` directly.
93+
94+
## From the terminal
95+
96+
The same operations are available headlessly:
97+
98+
```bash
99+
ocx integration client status
100+
ocx integration client enable --client hermes
101+
ocx integration client disable --client hermes
102+
ocx integration client history --client hermes
103+
ocx integration client restore --op <opId> [--confirm-drift]
104+
```
105+
106+
`--confirm-drift` is never assumed. If the file changed after the operation you are
107+
restoring, the command refuses and tells you, because replacing your newer edits is your
108+
decision to make.
109+
110+
Client details were verified against each project's own configuration format; see the
111+
research notes in `devlog/_plan/260802_client_toggle_api/002_client_toggle_matrix.md`
112+
for what was checked and when.

gui/src/i18n/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,7 @@ export const de: Record<TKey, string> = {
723723
"integrations.error.conflict": "Die Konfiguration wurde geändert, nachdem opencodex sie geschrieben hatte. Es wurde nichts entfernt.",
724724
"integrations.error.unsafe": "Die Konfiguration kann nicht sicher geändert werden.",
725725
"integrations.error.generic": "Die Integrationsänderung ist fehlgeschlagen. Der vorherige Zustand wurde beibehalten.",
726+
"integrations.error.nonLoopback": "{client} erreicht nur einen Proxy auf localhost. In seiner Konfiguration ist kein Platz für den Header, den eine Remote-Bindung verlangt — von Hand geschrieben hilft es also ebenso wenig. Ermöglichen Sie stattdessen Loopback-Zugriff, etwa über einen Tunnel oder lokalen Forwarder.",
726727
"integrations.status.installed": "Installiert",
727728
"integrations.status.notInstalled": "Nicht installiert",
728729
"integrations.status.appliedAt": "Angewendet",

gui/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,7 @@ export const en = {
11651165
"integrations.error.conflict": "The config changed after opencodex wrote it. Nothing was removed.",
11661166
"integrations.error.unsafe": "The config cannot be changed safely.",
11671167
"integrations.error.generic": "The integration change failed. Your previous state was kept.",
1168+
"integrations.error.nonLoopback": "{client} can only reach a proxy on localhost — its config has nowhere to put the admission header a remote bind requires, so writing one by hand would not help either. Give it loopback access instead, through a tunnel or a local forwarder.",
11681169
"integrations.status.installed": "Installed",
11691170
"integrations.status.notInstalled": "Not installed",
11701171
"integrations.status.appliedAt": "Applied",

0 commit comments

Comments
 (0)