Skip to content

Commit 10acf92

Browse files
authored
feat(tools): add codex-diff redacted config comparison tool (#129)
Phase 4 F3 — compares two JSON snapshots (account storage, exported backups, opencode.json) and emits a structural diff with added/removed/changed entries keyed by dot-separated JSON paths. All emitted values pass through maskString (tokens, JWTs, emails) and home directories are replaced with <HOME> in both input paths and any string values. Output is safe to paste into bug reports. Supports a section filter ('accounts' | 'config' | 'both') so callers can scope the comparison to just the accounts array or just the surrounding config. Adds 12 tests covering: added/removed/changed detection, JWT redaction, home-path redaction, missing file / invalid JSON graceful errors, section filtering, identical-file empty diff, and ISO timestamp emission. Refs: docs/audits/08-feature-recommendations.md; docs/audits/13-phased-roadmap.md §4.
1 parent 9115f4d commit 10acf92

3 files changed

Lines changed: 668 additions & 0 deletions

File tree

lib/tools/codex-diff.ts

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
/**
2+
* `codex-diff` tool — redacted structural diff of two config snapshots.
3+
*
4+
* Given two JSON file paths (typically account storage files, exported
5+
* account backups, or `opencode.json` configs) the tool:
6+
*
7+
* - reads both files and parses them as JSON
8+
* - walks the JSON tree and emits a sorted list of `added | removed |
9+
* changed` entries keyed by dot-separated JSON paths
10+
* - redacts every emitted value through `maskString` (tokens, JWTs,
11+
* emails) and strips the user's home directory from both input paths
12+
* and any string values
13+
*
14+
* The output is safe to paste into bug reports or CI logs: tokens,
15+
* refresh tokens, and `~/…` usernames never survive verbatim.
16+
*
17+
* Intended use cases:
18+
*
19+
* - "did my local storage drift from a known-good backup?"
20+
* - "what is different between worktree A and worktree B config?"
21+
* - "compare a per-project account file against the global file"
22+
*
23+
* See docs/audits/08-feature-recommendations.md and
24+
* docs/audits/13-phased-roadmap.md §4 (Phase 4 F3).
25+
*/
26+
import { promises as fs } from "node:fs";
27+
import { homedir } from "node:os";
28+
29+
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
30+
31+
import { maskString } from "../logger.js";
32+
import type { ToolContext } from "./index.js";
33+
34+
export interface CodexDiffEntry {
35+
/**
36+
* Dot-separated JSON path of the leaf value, e.g.
37+
* `accounts[0].refreshToken` or `schemaVersion`.
38+
*/
39+
path: string;
40+
kind: "added" | "removed" | "changed";
41+
/** Redacted string representation of the left-hand value. */
42+
leftValue?: string;
43+
/** Redacted string representation of the right-hand value. */
44+
rightValue?: string;
45+
}
46+
47+
export interface CodexDiffResult {
48+
leftPath: string;
49+
rightPath: string;
50+
section: "accounts" | "config" | "both";
51+
entries: CodexDiffEntry[];
52+
summary: {
53+
added: number;
54+
removed: number;
55+
changed: number;
56+
};
57+
redactionApplied: true;
58+
generatedAt: string;
59+
}
60+
61+
export interface CodexDiffError {
62+
error: "cannot-read" | "invalid-json";
63+
side: "left" | "right";
64+
path: string;
65+
message: string;
66+
redactionApplied: true;
67+
generatedAt: string;
68+
}
69+
70+
/**
71+
* Replace occurrences of the user's home directory with the placeholder
72+
* `<HOME>` so shared diff output never leaks the reporter's username.
73+
* Mirrors the logic in `codex-diag.ts` intentionally — the two tools
74+
* ship the same guarantee.
75+
*/
76+
function redactHomePaths(input: string): string {
77+
const home = homedir();
78+
if (!home) return input;
79+
const needles = new Set<string>();
80+
needles.add(home);
81+
needles.add(home.replace(/\\/g, "/"));
82+
needles.add(home.replace(/\\/g, "\\\\"));
83+
let output = input;
84+
for (const needle of needles) {
85+
if (!needle) continue;
86+
while (output.includes(needle)) {
87+
output = output.replace(needle, "<HOME>");
88+
}
89+
}
90+
return output;
91+
}
92+
93+
/** Redact a filesystem path for display in the diff output. */
94+
function redactPath(p: string): string {
95+
return redactHomePaths(p);
96+
}
97+
98+
/**
99+
* Convert a leaf JSON value into a redacted string representation.
100+
*
101+
* Non-string primitives are round-tripped through `JSON.stringify` so
102+
* numbers, booleans, and `null` keep their JSON form (`42`, `true`,
103+
* `null`). Any final string is passed through `maskString` so JWT- and
104+
* token-shaped substrings get masked, and then through `redactHomePaths`
105+
* as defence in depth against home directories embedded in values.
106+
*/
107+
function redactValue(value: unknown): string {
108+
if (value === undefined) return "undefined";
109+
const rendered =
110+
typeof value === "string" ? value : JSON.stringify(value);
111+
return redactHomePaths(maskString(rendered));
112+
}
113+
114+
/**
115+
* Collect every leaf `(path, value)` pair for a JSON-like value.
116+
*
117+
* Objects become dotted paths (`a.b.c`), arrays become indexed paths
118+
* (`a[0].b`). The root of an object or array produces no entry of its
119+
* own — only leaves are emitted. An explicit `null` IS a leaf value.
120+
*/
121+
function collectLeafPaths(
122+
value: unknown,
123+
prefix: string,
124+
out: Map<string, unknown>,
125+
): void {
126+
if (Array.isArray(value)) {
127+
if (value.length === 0) {
128+
out.set(prefix === "" ? "[]" : `${prefix}[]`, "[]");
129+
return;
130+
}
131+
value.forEach((item, index) => {
132+
const next = `${prefix}[${index}]`;
133+
collectLeafPaths(item, next, out);
134+
});
135+
return;
136+
}
137+
if (value !== null && typeof value === "object") {
138+
const keys = Object.keys(value as Record<string, unknown>);
139+
if (keys.length === 0) {
140+
out.set(prefix === "" ? "{}" : `${prefix}.{}`, "{}");
141+
return;
142+
}
143+
for (const key of keys) {
144+
const child = (value as Record<string, unknown>)[key];
145+
const next = prefix === "" ? key : `${prefix}.${key}`;
146+
collectLeafPaths(child, next, out);
147+
}
148+
return;
149+
}
150+
// Primitive leaf (string, number, boolean, null, undefined).
151+
out.set(prefix, value);
152+
}
153+
154+
function leafValuesEqual(a: unknown, b: unknown): boolean {
155+
// Structural equality for leaves. Most leaves are primitives; the
156+
// `"[]"`/`"{}"` sentinels emitted for empty containers are strings
157+
// which `Object.is` compares byte-for-byte.
158+
return Object.is(a, b);
159+
}
160+
161+
/**
162+
* Compute a structural diff between two parsed JSON values.
163+
*
164+
* Entries are sorted alphabetically by `path` so the output is stable
165+
* regardless of object key order in the source files.
166+
*/
167+
export function computeCodexDiff(
168+
left: unknown,
169+
right: unknown,
170+
): CodexDiffEntry[] {
171+
const leftPaths = new Map<string, unknown>();
172+
const rightPaths = new Map<string, unknown>();
173+
collectLeafPaths(left, "", leftPaths);
174+
collectLeafPaths(right, "", rightPaths);
175+
176+
const allKeys = new Set<string>();
177+
for (const key of leftPaths.keys()) allKeys.add(key);
178+
for (const key of rightPaths.keys()) allKeys.add(key);
179+
180+
const entries: CodexDiffEntry[] = [];
181+
for (const key of allKeys) {
182+
const hasLeft = leftPaths.has(key);
183+
const hasRight = rightPaths.has(key);
184+
const leftVal = leftPaths.get(key);
185+
const rightVal = rightPaths.get(key);
186+
if (hasLeft && hasRight) {
187+
if (leafValuesEqual(leftVal, rightVal)) continue;
188+
entries.push({
189+
path: key,
190+
kind: "changed",
191+
leftValue: redactValue(leftVal),
192+
rightValue: redactValue(rightVal),
193+
});
194+
} else if (hasRight) {
195+
entries.push({
196+
path: key,
197+
kind: "added",
198+
rightValue: redactValue(rightVal),
199+
});
200+
} else {
201+
entries.push({
202+
path: key,
203+
kind: "removed",
204+
leftValue: redactValue(leftVal),
205+
});
206+
}
207+
}
208+
entries.sort((a, b) => a.path.localeCompare(b.path));
209+
return entries;
210+
}
211+
212+
/**
213+
* Extract a sub-tree of the parsed snapshot based on the `section`
214+
* parameter. `both` (default) diffs the entire document; `accounts`
215+
* diffs only the `accounts` array; `config` diffs every top-level key
216+
* except `accounts`.
217+
*/
218+
function extractSection(
219+
value: unknown,
220+
section: "accounts" | "config" | "both",
221+
): unknown {
222+
if (section === "both") return value;
223+
if (value === null || typeof value !== "object") return value;
224+
const record = value as Record<string, unknown>;
225+
if (section === "accounts") {
226+
// Preserve the key so paths remain readable (`accounts[0].foo`).
227+
return { accounts: record.accounts ?? [] };
228+
}
229+
// section === "config": everything except `accounts`.
230+
const { accounts: _accounts, ...rest } = record;
231+
void _accounts;
232+
return rest;
233+
}
234+
235+
async function readJsonFile(
236+
path: string,
237+
): Promise<{ ok: true; value: unknown } | { ok: false; error: CodexDiffError["error"]; message: string }> {
238+
let raw: string;
239+
try {
240+
raw = await fs.readFile(path, "utf8");
241+
} catch (error) {
242+
return {
243+
ok: false,
244+
error: "cannot-read",
245+
message:
246+
error instanceof Error ? error.message : String(error),
247+
};
248+
}
249+
try {
250+
return { ok: true, value: JSON.parse(raw) as unknown };
251+
} catch (error) {
252+
return {
253+
ok: false,
254+
error: "invalid-json",
255+
message:
256+
error instanceof Error ? error.message : String(error),
257+
};
258+
}
259+
}
260+
261+
export function createCodexDiffTool(_ctx: ToolContext): ToolDefinition {
262+
// The tool is intentionally closure-free — it depends on nothing
263+
// from `ToolContext`. We still take `ctx` so the factory signature
264+
// matches every other `codex-*` tool and future additions (e.g.
265+
// routing-snapshot embedding) don't break callers.
266+
void _ctx;
267+
return tool({
268+
description:
269+
"Compare two JSON config/account snapshots and emit a redacted structural diff. Tokens, emails, and home paths are masked; output is safe to share.",
270+
args: {
271+
left: tool.schema
272+
.string()
273+
.describe("Path to the left-hand JSON file (baseline)."),
274+
right: tool.schema
275+
.string()
276+
.describe(
277+
"Path to the right-hand JSON file (comparison target).",
278+
),
279+
section: tool.schema
280+
.string()
281+
.optional()
282+
.describe(
283+
"Which part of the document to diff: 'accounts' (accounts array only), 'config' (everything except accounts), or 'both' (default, whole document).",
284+
),
285+
},
286+
async execute({ left, right, section }) {
287+
const normalized = (section ?? "both").trim().toLowerCase();
288+
const effectiveSection: "accounts" | "config" | "both" =
289+
normalized === "accounts" ||
290+
normalized === "config" ||
291+
normalized === "both"
292+
? (normalized as "accounts" | "config" | "both")
293+
: "both";
294+
const [leftResult, rightResult] = await Promise.all([
295+
readJsonFile(left),
296+
readJsonFile(right),
297+
]);
298+
299+
const generatedAt = new Date().toISOString();
300+
301+
if (!leftResult.ok) {
302+
const errorPayload: CodexDiffError = {
303+
error: leftResult.error,
304+
side: "left",
305+
path: redactPath(left),
306+
message: redactHomePaths(maskString(leftResult.message)),
307+
redactionApplied: true,
308+
generatedAt,
309+
};
310+
return redactHomePaths(
311+
maskString(JSON.stringify(errorPayload, null, 2)),
312+
);
313+
}
314+
if (!rightResult.ok) {
315+
const errorPayload: CodexDiffError = {
316+
error: rightResult.error,
317+
side: "right",
318+
path: redactPath(right),
319+
message: redactHomePaths(maskString(rightResult.message)),
320+
redactionApplied: true,
321+
generatedAt,
322+
};
323+
return redactHomePaths(
324+
maskString(JSON.stringify(errorPayload, null, 2)),
325+
);
326+
}
327+
328+
const leftSection = extractSection(
329+
leftResult.value,
330+
effectiveSection,
331+
);
332+
const rightSection = extractSection(
333+
rightResult.value,
334+
effectiveSection,
335+
);
336+
337+
const entries = computeCodexDiff(leftSection, rightSection);
338+
const summary = entries.reduce(
339+
(acc, entry) => {
340+
acc[entry.kind] += 1;
341+
return acc;
342+
},
343+
{ added: 0, removed: 0, changed: 0 } as CodexDiffResult["summary"],
344+
);
345+
346+
const result: CodexDiffResult = {
347+
leftPath: redactPath(left),
348+
rightPath: redactPath(right),
349+
section: effectiveSection,
350+
entries,
351+
summary,
352+
redactionApplied: true,
353+
generatedAt,
354+
};
355+
356+
// Defence in depth: mask any stray token-shaped substring and
357+
// scrub home paths one more time across the rendered JSON.
358+
return redactHomePaths(
359+
maskString(JSON.stringify(result, null, 2)),
360+
);
361+
},
362+
});
363+
}

lib/tools/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ import { createCodexRefreshTool } from "./codex-refresh.js";
5353
import { createCodexExportTool } from "./codex-export.js";
5454
import { createCodexImportTool } from "./codex-import.js";
5555
import { createCodexDiagTool } from "./codex-diag.js";
56+
import { createCodexDiffTool } from "./codex-diff.js";
5657

5758
export { createCodexDiagTool } from "./codex-diag.js";
59+
export { createCodexDiffTool } from "./codex-diff.js";
5860

5961
/**
6062
* Mutable reference wrapper.
@@ -242,5 +244,6 @@ export function createToolRegistry(ctx: ToolContext): CodexToolRegistry {
242244
"codex-export": createCodexExportTool(ctx),
243245
"codex-import": createCodexImportTool(ctx),
244246
"codex-diag": createCodexDiagTool(ctx),
247+
"codex-diff": createCodexDiffTool(ctx),
245248
};
246249
}

0 commit comments

Comments
 (0)