|
| 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 | +} |
0 commit comments