← back to Kickbacks Ai Upstream
src/adapters/codex/adapter.ts
298 lines
import { readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import type { TargetAdapter, PreflightResult, OpResult, RestoreResult, PatchParams }
from "../types";
import { sha256 } from "../../util/crypto";
import { resolveAsset } from "../../util/asset";
// Strip a previously-injected block (current OR legacy markers) so re-apply
// never stacks and preflight evaluates the pristine shape — same reversibility
// contract as the Claude Code adapter.
const BLOCK_START = "/* VIBE-ADS-START */";
const BLOCK_END = "/* VIBE-ADS-END */";
// Codex injection is an INLINE wrapper `arg=(<block>)||arg;`, unlike the
// Claude Code adapter's appended standalone block. Stripping must remove the
// WHOLE wrapper, not just the comment-delimited body — else a re-derive from
// an already-patched file (no backup) leaves an empty `arg=()||arg;`, a hard
// syntax error that ACCUMULATES on every reassert (the "finnicky / stopped
// working" root cause). Three forms are stripped, in order:
// 1. current: /* START */arg=(...)||arg;/* END */ (markers OUTSIDE)
// 2. legacy: arg=(/* START */...../* END */)||arg; (markers inside)
// 3. residue: arg=()||arg; (legacy form after a markers-only strip)
const ID = "[A-Za-z_$][\\w$]*";
const STRIP_RES: RegExp[] = [
// 1 + 2: optional `ident=(` prefix and `)||ident;` suffix around the markers
new RegExp(
"(?:" + ID + "=\\()?" +
"\\/\\* VIB(?:E-)?ADS-START \\*\\/[\\s\\S]*?\\/\\* VIB(?:E-)?ADS-END \\*\\/" +
"(?:\\)\\|\\|" + ID + ";)?", "g"),
// 3: empty-wrapper residue. `=()||ident;` cannot occur in valid minified JS
// (`()` is not a valid expression), so matching it is safe and targeted.
new RegExp(ID + "=\\(\\)\\|\\|" + ID + ";", "g"),
];
/** Remove every historical Vibe-Ads injection form so a re-derived pristine is
* byte-true to the original chunk (self-heals accumulated corruption). */
function stripInjection(s: string): string {
for (const re of STRIP_RES) s = s.replace(re, "");
return s;
}
// Whitespace-tolerant: the real chunk is minified (`export{v as n,...}`) but
// tolerating optional spaces is strictly safer against minifier variation and
// still matches the minified form.
const EXPORT_RE = /export\s*\{([^}]*)\}/; // the chunk's export map
const JSX_RE = /\(0,\s*([A-Za-z0-9_$]+)\.jsxs?\)/; // (0,d.jsx) | (0,d.jsxs)
const CSP_CONNECT_RE = /`connect-src\s+([^`]*)`/g;
const CSP_MARK = "connect-src http://127.0.0.1:*";
const CSP_INSERT = "http://127.0.0.1:* http://localhost:*";
/** Resolve the shipped Codex block asset relative to `baseDir` (= dirname of
* the running adapter file). Unbundled vitest: co-located. esbuild-bundled
* VSIX: dist/adapters/codex/. Try both; first existing wins — mirrors the
* Claude Code adapter's resolveBlockAsset contract (the S3 Wave-1 CRIT). */
export function resolveCodexBlockAsset(baseDir: string): string {
return resolveAsset(baseDir, "adapters/codex", "block.asset.js");
}
export class CodexAdapter implements TargetAdapter {
readonly name = "codex";
private readonly target: string;
constructor(target: string) { this.target = resolve(target); }
private backupPath(): string { return this.target + ".vibe-ads-backup"; }
// Pre-rename installs wrote ".vibads-backup"; prefer any existing backup so
// we never overwrite the real pristine nor falsely report "no backup".
private legacyBackupPath(): string { return this.target + ".vibads-backup"; }
private existingBackupPath(): string | null {
if (existsSync(this.backupPath())) return this.backupPath();
if (existsSync(this.legacyBackupPath())) return this.legacyBackupPath();
return null;
}
private extensionRoot(): string {
// target = <ext>/webview/assets/thinking-shimmer-<hash>.js
return dirname(dirname(dirname(this.target)));
}
private extTarget(): string | null {
const root = this.extensionRoot();
const candidates = [
join(root, "out", "extension.js"),
join(root, "extension.js"),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return null;
}
private extBackupPath(ext: string): string {
return ext + ".vibe-ads-backup";
}
/** Codex's overlay can render while CSP blocks every loopback fetch. Add
* loopback hosts to Codex's existing connect-src template so impression,
* view_tick, click, and debug-log pings can leave the webview. */
private patchCspWithReason():
{ ok: boolean; reason?: "no-sibling" | "already" | "anchor-missing" | "io-err" } {
try {
const ext = this.extTarget();
if (!ext) return { ok: false, reason: "no-sibling" };
const src = readFileSync(ext, "utf8");
if (src.includes(CSP_MARK)) return { ok: true, reason: "already" };
let changed = false;
const patched = src.replace(CSP_CONNECT_RE, (_m, rest: string) => {
changed = true;
return "`connect-src " + CSP_INSERT + " " + rest.trim() + "`";
});
if (!changed) return { ok: false, reason: "anchor-missing" };
const bak = this.extBackupPath(ext);
if (!existsSync(bak))
writeFileSync(bak, Buffer.from(src, "utf8"));
writeFileSync(ext, Buffer.from(patched, "utf8"));
return { ok: true };
} catch {
return { ok: false, reason: "io-err" };
}
}
/** Prime the loopback connect-src CSP relaxation WITHOUT injecting a block.
* See TargetAdapter.prime. Idempotent + reversible (same keepCsp contract
* as applyPatch's CSP layer); a later applyPatch is a no-op on the CSP. */
prime(): OpResult {
const r = this.patchCspWithReason();
return { ok: r.ok, reason: r.reason };
}
private restoreCsp(): void {
try {
const ext = this.extTarget();
if (!ext) return;
const bak = this.extBackupPath(ext);
if (!existsSync(bak)) return;
const pristine = readFileSync(bak);
writeFileSync(ext, pristine);
if (sha256(readFileSync(ext)) === sha256(pristine))
rmSync(bak);
} catch { /* best-effort */ }
}
/** The ThinkingShimmer entry: the identifier re-exported `as n`, plus its
* `function NAME(ARG){` site (insertion point is just past the `{`). */
private locateEntry(src: string):
{ name: string; arg: string; at: number } | null {
const ex = EXPORT_RE.exec(src);
if (!ex) return null;
const m = /([A-Za-z0-9_$]+)\s+as\s+n\b/.exec(ex[1]);
if (!m) return null;
const sig = new RegExp(
"function\\s+" + m[1] + "\\s*\\(\\s*([A-Za-z0-9_$]+)\\s*\\)\\s*\\{"
).exec(src);
if (!sig) return null;
return { name: m[1], arg: sig[1], at: sig.index + sig[0].length };
}
private jsxName(src: string): string | null {
const m = JSX_RE.exec(src);
return m ? m[1] : null;
}
version(): string | null {
// .../openai.chatgpt-<ver>/webview/assets/thinking-shimmer-<hash>.js
const m = /openai\.chatgpt-([0-9][^/\\]*)/.exec(this.target);
return m ? m[1] : "unknown";
}
/** True iff the live target currently carries our injected block. */
isPatched(): boolean {
try {
return existsSync(this.target) &&
readFileSync(this.target, "utf8").includes(BLOCK_START);
} catch {
return false;
}
}
preflight(): PreflightResult {
try {
if (!existsSync(this.target))
return { ok: true, compatible: false, version: null,
reason: "target not found" };
// Evaluate against the pristine shape: a co-located backup means WE
// patched this exact file, and our block strip-by-marker keeps the
// anchor scan honest even on an already-patched live file.
const bak = this.existingBackupPath();
const raw = readFileSync(bak ?? this.target, "utf8");
const src = stripInjection(raw);
const ok = this.locateEntry(src) !== null
&& /defaultMessage:`Thinking`/.test(src)
&& this.jsxName(src) !== null;
return ok
? { ok: true, compatible: true, version: this.version() }
: { ok: true, compatible: false, version: this.version(),
reason: "thinking-shimmer anchors not found (incompatible build)" };
} catch (e) {
return { ok: false, compatible: false, version: null, reason: String(e) };
}
}
private renderBlock(p: PatchParams, arg: string, jsx: string): string {
const assetPath = resolveCodexBlockAsset(dirname(__filename));
let src = readFileSync(assetPath, "utf8").trim();
const subs: Record<string, string> = {
__VIBE_ADS_ARG__: arg, // bare identifier (e)
__VIBE_ADS_JSX__: jsx, // bare identifier (d)
__VIBE_ADS_AD__: JSON.stringify(p.adText),
__VIBE_ADS_PORT__: String(p.loopbackPort),
__VIBE_ADS_LBTOKEN__: JSON.stringify(p.loopbackToken),
__VIBE_ADS_BASE__: JSON.stringify(p.loopbackBase ?? ""),
__VIBE_ADS_CLICKTOKEN__: JSON.stringify(p.clickToken),
__VIBE_ADS_CLICKURL__: JSON.stringify(p.clickUrl),
__VIBE_ADS_CORR__: JSON.stringify(p.corr),
__VIBE_ADS_DEBUG__: p.debug ? "true" : "false",
__VIBE_ADS_VIEW_THRESHOLD_MS__: String(
typeof p.viewThresholdMs === "number" && p.viewThresholdMs > 0
? p.viewThresholdMs : 15000),
};
for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
return src;
}
applyPatch(p: PatchParams): OpResult {
try {
if (!existsSync(this.target))
return { ok: false, reason: "target not found" };
// Validate anchors on the pristine shape BEFORE taking a backup, so an
// incompatible build never leaves a stray .vibe-ads-backup behind.
const live = readFileSync(this.target, "utf8");
const bak = this.existingBackupPath();
// Re-derive the TRUE pristine: strip every historical injection form
// (current/legacy/residue) from the backup if present, else the live
// file. stripInjection is byte-true to the original chunk, so this
// self-heals a file corrupted by the old marker-only strip.
const pristine = stripInjection(
bak ? readFileSync(bak, "utf8") : live);
const loc = this.locateEntry(pristine);
const jsx = this.jsxName(pristine);
if (!loc || !jsx) return { ok: false, reason: "anchors not found" };
// One-time backup of the CLEAN pristine — never a patched/corrupted
// snapshot (the old ensureBackup stored raw live bytes, which is how a
// backup itself could become poisoned).
if (!bak)
writeFileSync(this.backupPath(), Buffer.from(pristine, "utf8"));
const block = this.renderBlock(p, loc.arg, jsx);
// Inject as the entry's first statement, BEFORE the props destructure +
// React-Compiler memo cache. Markers wrap the WHOLE `arg=(IIFE)||arg;`
// statement (OUTSIDE the wrapper) so a future strip removes it entirely
// — no empty-wrapper residue can ever be left behind. The IIFE always
// returns undefined (`arg = undefined || arg`) so Codex's component is
// never altered; the ad renders via a body-level overlay.
const out = pristine.slice(0, loc.at) +
BLOCK_START + loc.arg + "=(" + block + ")||" + loc.arg + ";" +
BLOCK_END + pristine.slice(loc.at);
const buf = Buffer.from(out, "utf8");
if (sha256(buf) !== sha256(readFileSync(this.target)))
writeFileSync(this.target, buf);
this.patchCspWithReason();
return { ok: true };
} catch (e) {
return { ok: false, reason: String(e) };
}
}
// `keepCsp` is part of the shared TargetAdapter contract. Routine extension
// deactivate keeps the invisible loopback-only CSP relaxation; explicit
// restore / kill / sign-out fully reverts it.
restore(opts?: { keepCsp?: boolean }): RestoreResult {
try {
const bak = this.existingBackupPath();
if (bak === null) {
if (!opts?.keepCsp) this.restoreCsp();
return { ok: true, restored: false, reason: "no backup present" };
}
const pristine = readFileSync(bak);
writeFileSync(this.target, pristine);
if (sha256(readFileSync(this.target)) !== sha256(pristine))
return { ok: false, restored: false,
reason: "sha256 mismatch after restore" };
rmSync(bak);
if (!opts?.keepCsp) this.restoreCsp();
return { ok: true, restored: true };
} catch (e) {
return { ok: false, restored: false, reason: String(e) };
}
}
}
/** Spec §4.4 display gate (the tested mirror of the block-asset inline check).
* Codex's ThinkingShimmer entry receives `e.message`: null/undefined when it
* is about to render its own i18n "Thinking" placeholder, or a status element
* (`{props}`) the host wants shown instead. We override ONLY the generic
* thinking surface — every real tool / approval / reviewer / arbitrary status
* passes through untouched (prime directive: never mask real UI). */
export function isThinkingMessage(m: unknown): boolean {
if (m == null) return true;
if (typeof m !== "object") return false;
const p = (m as { props?: Record<string, unknown> }).props;
if (!p) return false;
return p.id === "reasoningItem.thinking"
|| p.id === "thinkingShimmer.default"
|| p.defaultMessage === "Thinking";
}