From bf36c684d7409d8f89cb5e4edcc5611c6fbc4444 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:02:11 +0900 Subject: [PATCH] fix(zcode): bound default bootstrap file reads Reuse the integration filesystem seam and preserve current relocation behavior. Split the ZCode boundary from PR #719 on top of PR #723. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/content/docs/guides/integrations.md | 3 + src/clients/config-export.ts | 6 +- structure/clients/integrations.md | 1 + tests/providers/zcode-client.test.ts | 61 ++++++++++++++++++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 47ed37cd88a..22632565db7 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -571,6 +571,9 @@ the provider store moves to `/.zcode/v2/provider_config.json`. `ZCODE_DATA_BASE_DIR` is a separate native selector and is not an alias for `ZCODE_DATA_DIR`. Unreadable, malformed or relative bootstrap settings leave the active root unknown. +The bootstrap must be a regular file of at most 4 MiB; symbolic links and special files +are refused during export and integration path discovery. Repair the bootstrap file or +supply an explicit `ZCODE_DATA_DIR` to select the intended data root. Apply, overwrite and refresh refuse with a repair message instead of claiming a successful legacy write. An existing explicit provider file can still establish the active store. Ownership recorded at the original default config or store keeps its path, fingerprints diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index e28ed0b4242..f01ea71d14e 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -24,6 +24,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "nod import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; +import { fileIO } from "../integrations/config-io"; import { canonicalizeReasoningEfforts } from "../reasoning-effort"; import { expandFastExportModels } from "./config-export/fast-models"; import { probeHostname } from "../server/proxy-liveness"; @@ -475,10 +476,7 @@ export type ZcodePathIO = { readText: (path: string) => { kind: "text"; text: string } | { kind: "missing" } | { kind: "failed"; code?: string }; }; const zcodePathIO: ZcodePathIO = { - readText: path => { - try { return { kind: "text", text: readFileSync(path, "utf8") }; } - catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "missing" } : { kind: "failed" }; } - }, + readText: path => fileIO().readText(path), }; export class ZcodeSettingsPathError extends ClientPathError {} diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 662e29d44e7..b8b67c3f624 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -381,6 +381,7 @@ observed. Naming the store without the last three would be naming a file we cann ZCode resolves absolute `dataBaseDir` from the default-home `~/.zcode/v2/setting.json` through the caller's `IntegrationIO`, moving data to `/.zcode/v2`. `ZCODE_DATA_DIR` overrides the `.zcode` root directly; `ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` overrides the store. Native `ZCODE_DATA_BASE_DIR` is not an alias. The registry resolves config, detection and store from one bootstrap read. Synchronous observations re-read after coordinator awaits; store-lock and restore probes share the injected IO. Malformed, unreadable or relative settings refuse apply/overwrite/refresh as an unknown active root. Only a present explicit provider file can establish the store independently. +Default ZCode bootstrap path resolution uses the same bounded regular-file reader as integration operations. A final symlink, FIFO or file above 4 MiB leaves the active root unknown; only a missing bootstrap file permits the default-root fallback. An explicit `ZCODE_DATA_DIR` still bypasses bootstrap discovery. Owned legacy config and default-root store paths retain their fingerprints and recovery evidence after relocation: refresh refuses, disable and undo use the original file, and an older undo refuses while a different path owns the record. Unknown settings retain recognized historical recovery targets; an unrecognized prior selector must be restored before mutation. Contract: `tests/clients/integrations-current-store.test.ts`. diff --git a/tests/providers/zcode-client.test.ts b/tests/providers/zcode-client.test.ts index ab34e9bab4b..4d171fa44fd 100644 --- a/tests/providers/zcode-client.test.ts +++ b/tests/providers/zcode-client.test.ts @@ -1,13 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { ClientPathError, LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID, + ZcodeSettingsPathError, buildClientConfig, buildClientConfigText, buildClientContribution, buildZcodeStoreContribution, + zcodeBootstrapSettingsPath, zcodeConfigPath, zcodeHomeDir, type ExportContext, @@ -16,6 +21,8 @@ import { } from "../../src/clients/config-export"; import type { OcxConfig } from "../../src/types"; import { handleZcodeCommand } from "../../src/cli/integrations"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const CONFIG = { port: 10100, @@ -298,3 +305,55 @@ describe("ocx zcode CLI alias", () => { expect(requests.some(r => r.path === "/api/client-integrations/restore" && r.method === "POST" && (r.body as { opId?: string }).opId === "op-1")).toBe(true); }); }); + +function withBootstrapFixture(run: (home: string, settings: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-zcode-bootstrap-")); + const settings = zcodeBootstrapSettingsPath(home); + mkdirSync(dirname(settings), { recursive: true }); + try { run(home, settings); } finally { removeTreeWithRetry(home); } +} +test("default ZCode bootstrap lookup preserves absence and ordinary relocation", () => { + withBootstrapFixture((home, settings) => { + expect(zcodeHomeDir({}, home)).toBe(join(home, ".zcode")); + const base = join(home, "relocated"); + writeFileSync(settings, JSON.stringify({ dataBaseDir: base })); + expect(zcodeHomeDir({}, home)).toBe(join(base, ".zcode")); + expect(zcodeConfigPath({}, home)).toBe(join(base, ".zcode", "v2", "config.json")); + }); +}); +test("default ZCode bootstrap lookup enforces the shared four-MiB boundary", () => { + withBootstrapFixture((home, settings) => { + const bounded = "{}" + " ".repeat(4 * 1024 * 1024 - 2); + writeFileSync(settings, bounded); + expect(zcodeHomeDir({}, home)).toBe(join(home, ".zcode")); + writeFileSync(settings, bounded + " "); + expect(() => zcodeHomeDir({}, home)).toThrow(ZcodeSettingsPathError); + expect(readFileSync(settings, "utf8")).toBe(bounded + " "); + expect(zcodeHomeDir({ ZCODE_DATA_DIR: join(home, "explicit") }, home)).toBe(join(home, "explicit")); + }); +}); +test("default ZCode bootstrap lookup refuses final symlinks without changing the target", () => { + withBootstrapFixture((home, settings) => { + const target = join(home, "target.json"); + const content = JSON.stringify({ dataBaseDir: join(home, "relocated") }); + writeFileSync(target, content); + symlinkSync(target, settings, "file"); + expect(() => zcodeHomeDir({}, home)).toThrow(ZcodeSettingsPathError); + expect(readFileSync(target, "utf8")).toBe(content); + expect(lstatSync(settings).isSymbolicLink()).toBe(true); + unlinkSync(target); + expect(() => zcodeHomeDir({}, home)).toThrow(ZcodeSettingsPathError); + }); +}); +test.skipIf(process.platform === "win32")("default ZCode bootstrap lookup refuses a FIFO without waiting for a writer", () => { + withBootstrapFixture((home, settings) => { + expect(spawnSync("mkfifo", [settings], { timeout: 2_000 }).status).toBe(0); + const result = spawnSync(process.execPath, ["--eval", ` + const { zcodeHomeDir, ZcodeSettingsPathError } = await import("./src/clients/config-export.ts"); + try { zcodeHomeDir({}, process.env.ZCODE_BOOTSTRAP_TEST_HOME); process.exitCode = 2; } + catch (error) { if (!(error instanceof ZcodeSettingsPathError)) throw error; } + `], { cwd: repoPath(), env: { ...process.env, ZCODE_BOOTSTRAP_TEST_HOME: home }, timeout: 5_000 }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }); +}, 10_000);