Skip to content

Commit 52a9fa2

Browse files
committed
fix(integrations): close the two holes the blocked-container guard left open
A re-review of yesterday's fix found it incomplete in one direction and crashing in another. `typeof null === "object"`, so a document that is literally `null` walked past the guard: `blockedContainerPath` treated it as an absent prefix, apply reported success, and the user's file was replaced wholesale. Only `undefined` means absent — a missing file parses as `{}`, while a parsed `null` is a value the file actually contains. And `disableIntegration` still assumed everything past the conflict branch has an ownership record, which a blocked container never does. Direct disable threw a TypeError and the management route answered 500. The GUI locks the switch for `unsafe`, but `ocx integration client disable` and API callers do not — the surface that hides a defect is not the surface that has to be correct. Tests added for both, plus OpenClaw's nested `models.providers` collision, which a one-level check would miss, and the IPv6/wildcard base URLs asserted against the emitted bytes rather than the helper — bypassing the shared composer is what caused that defect in the first place, so a helper-level test would not have caught it. Both new guards driven red before restoring.
1 parent 3bc89c2 commit 52a9fa2

3 files changed

Lines changed: 107 additions & 2 deletions

File tree

src/integrations/state.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,19 @@ export function blockedContainerPath(
7373
let cursor: unknown = doc;
7474
for (let depth = 0; depth < fragment.path.length - 1; depth += 1) {
7575
const key = fragment.path[depth]!;
76-
if (cursor === undefined || cursor === null) break;
77-
if (typeof cursor !== "object" || Array.isArray(cursor)) return fragment.path.slice(0, depth);
76+
/*
77+
* ONLY `undefined` means absent. A missing file parses as `{}`, so an
78+
* absent prefix reads `undefined` — but a parsed `null` is a value the
79+
* user's file actually contains, and treating it as absent let a
80+
* document that is literally `null` be replaced wholesale by a
81+
* "successful" apply.
82+
*/
83+
if (cursor === undefined) break;
84+
// `typeof null === "object"`, so null has to be named explicitly or it
85+
// walks straight into the dereference below.
86+
if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) {
87+
return fragment.path.slice(0, depth);
88+
}
7889
const next = (cursor as Record<string, unknown>)[key];
7990
if (next === undefined) break;
8091
if (typeof next !== "object" || next === null || Array.isArray(next)) {

src/integrations/writer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,19 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
304304
? `${configPath} changed after opencodex wrote it; disabling would discard that edit`
305305
: `${configPath} contains an opencodex block we did not write`);
306306
}
307+
/*
308+
* `unsafe` reaches here the same way it reaches apply, and the code below
309+
* dereferences `record` on the assumption that anything past this point is
310+
* `current` or `stale`. A blocked container has no record, so disable threw
311+
* a TypeError and the route answered 500 — the GUI locks the switch, but the
312+
* CLI and direct API callers do not.
313+
*/
314+
if (classified.state === "unsafe") {
315+
return refuse(clientId, "unsafe", "unsafe",
316+
classified.reason === "blocked-container"
317+
? `${configPath} holds a value where opencodex would have to read a section, so nothing can be removed safely`
318+
: `${configPath} cannot be changed safely`);
319+
}
307320

308321
// current | stale only: the file fingerprint still matches our record, so the
309322
// recorded paths are exactly what we put there.

tests/integrations-invariants.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,87 @@ describe("a container we would have to replace is refused, not overwritten", ()
248248
expect(store.listOperations()).toHaveLength(0);
249249
});
250250
}
251+
252+
test("openclaw: a collision in the NESTED container is refused too", () => {
253+
// OpenClaw's fragment path is two segments (`models.providers`), so a
254+
// one-level check would miss a collision at the inner container.
255+
const configPath = installClient("openclaw");
256+
const seed = '{\n models: {\n providers: ["user-value"],\n },\n}\n';
257+
writeFileSync(configPath, seed);
258+
259+
const result = applyIntegration({
260+
clientId: "openclaw", models: MODELS, config: CONFIG, port: 10100,
261+
env: TEST_ENV, home, store,
262+
});
263+
expect(result.ok).toBe(false);
264+
if (!result.ok) expect(result.reason).toBe("unsafe");
265+
expect(readFileSync(configPath, "utf8")).toBe(seed);
266+
});
267+
268+
test("a document that is literally null is not treated as absent", () => {
269+
/*
270+
* A missing file parses as `{}`, so an absent prefix reads `undefined`.
271+
* A parsed `null` is a value the file actually contains — treating it as
272+
* absent let apply replace the whole document and report success.
273+
*/
274+
const configPath = installClient("pi");
275+
writeFileSync(configPath, "null\n");
276+
277+
const result = applyIntegration({
278+
clientId: "pi", models: MODELS, config: CONFIG, port: 10100,
279+
env: TEST_ENV, home, store,
280+
});
281+
expect(result.ok).toBe(false);
282+
expect(readFileSync(configPath, "utf8")).toBe("null\n");
283+
});
284+
285+
test("disable refuses a blocked container instead of throwing", () => {
286+
/*
287+
* The GUI locks the switch for `unsafe`, but `ocx integration client
288+
* disable` and direct API callers do not — and the removal path
289+
* dereferences a record that a blocked container never has, so this threw
290+
* a TypeError and surfaced as a 500.
291+
*/
292+
const configPath = installClient("pi");
293+
const seed = '{\n "providers": ["user-value"]\n}\n';
294+
writeFileSync(configPath, seed);
295+
296+
const result = disableIntegration({
297+
clientId: "pi", models: MODELS, config: CONFIG, port: 10100,
298+
env: TEST_ENV, home, store,
299+
});
300+
expect(result.ok).toBe(false);
301+
if (!result.ok) expect(result.reason).toBe("unsafe");
302+
expect(readFileSync(configPath, "utf8")).toBe(seed);
303+
});
304+
});
305+
306+
describe("the base URL is composed, never interpolated", () => {
307+
test("IPv6 and wildcard binds produce a URL a client can actually dial", () => {
308+
/*
309+
* The defect was bypassing the shared composer, so this asserts the
310+
* emitted bytes rather than the helper — bypassing it again would pass a
311+
* helper-level test.
312+
*/
313+
const cases: [string, string][] = [
314+
["::1", "http://[::1]:10100/v1"],
315+
["::", "http://127.0.0.1:10100/v1"],
316+
["0.0.0.0", "http://127.0.0.1:10100/v1"],
317+
];
318+
for (const [hostname, expected] of cases) {
319+
const configPath = installClient("hermes");
320+
writeFileSync(configPath, "providers: {}\n");
321+
const result = applyIntegration({
322+
clientId: "hermes", models: MODELS, port: 10100,
323+
config: { ...CONFIG, hostname } as OcxConfig,
324+
env: TEST_ENV, home, store,
325+
});
326+
expect(result.ok).toBe(true);
327+
expect(readFileSync(configPath, "utf8")).toContain(expected);
328+
rmSync(configPath, { force: true });
329+
store.dropRecord("hermes");
330+
}
331+
});
251332
});
252333

253334
describe("a restore never launders a foreign edit into owned content", () => {

0 commit comments

Comments
 (0)