← back to Kickbacks Ai Upstream
src/adapters/claude-code/adapter.ts
455 lines
import { readFileSync, writeFileSync, existsSync, rmSync,
renameSync, unlinkSync, statSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import type { TargetAdapter, PreflightResult, OpResult, RestoreResult, PatchParams,
AdapterDiagnostics } from "../types";
import { dlog } from "../../log";
import { sha256 } from "../../util/crypto";
import { resolveAsset } from "../../util/asset";
// Distinctive nonsense/brand verbs from Claude Code's action-verb array. We
// anchor on the SET, not a single literal, so renaming or removing any ONE verb
// in a future CC release doesn't break detection — the array is still located
// as long as ANY of these survive. All verified present (once each) in CC
// 2.1.161's 84-verb array; they're made-up/brand words extremely unlikely to
// appear elsewhere in the bundle, so a false match on a different array is
// implausible. (Deep dive 2026-06-03; see [[cc-platform-specific-ext-dir]].)
const ANCHORS = [
'"Discombobulating"', '"Flibbertigibbeting"', '"Combobulating"',
'"Clauding"', '"Reticulating"', '"Smooshing"', '"Wibbling"', '"Booping"',
];
// >=2 comma-separated double-quoted tokens (the S0 multi-element requirement).
const ARRAY_RE = /\[(?:"[^"\\]*"\s*,\s*)+"[^"\\]*"\]/g;
const BLOCK_START = "/* VIBE-ADS-START */";
// Strip a previously-injected block delimited by EITHER the current markers
// or the legacy /* VIBADS-START/END */ markers. An install patched before the
// Vibe-Ads rename still carries the old markers; re-apply must not stack the
// block and restore-via-Tier-0 must still find it (reversibility contract).
const BLOCK_RE = /\/\* VIB(?:E-)?ADS-START \*\/[\s\S]*?\/\* VIB(?:E-)?ADS-END \*\//g;
/** Resolve the shipped block asset relative to `baseDir` (= dirname of the
* running adapter file). In the esbuild-bundled VSIX the adapter is inlined
* into dist/extension.js so baseDir=<dist> and the asset lives at
* dist/adapters/claude-code/block.asset.js (per esbuild.mjs). In unbundled
* vitest the adapter is src/adapters/claude-code/adapter.ts so the asset is
* co-located. Try both; first existing wins. (S3 Wave 1 review CRIT #1.) */
export function resolveBlockAsset(baseDir: string): string {
return resolveAsset(baseDir, "adapters/claude-code", "block.asset.js");
}
// Atomic file replacement: write the new bytes to a temp sibling, then
// `rename` the temp over the target. POSIX `rename` is atomic; on Win32
// it is "best-effort atomic" but still crash-safe in practice — the
// target either holds the OLD bytes or the NEW bytes, never a partial
// write. This closes the wave-2A race where a power loss / CC self-
// update collision mid-`writeFileSync` could leave webview/index.js
// truncated. Falls back to a direct write if rename fails (e.g. on
// some Windows filesystems where ENOENT can race the temp creation),
// so the worst case stays the prior behavior, not a refusal to write.
function atomicWriteFile(target: string, data: Buffer): void {
const tmp = target + ".kickbacks-tmp-" + process.pid + "-" + Date.now();
try {
writeFileSync(tmp, data);
renameSync(tmp, target);
} catch {
// best-effort cleanup of the temp; then fall back to a direct write
// so we don't fail-closed on systems where rename semantics differ.
try { unlinkSync(tmp); } catch { /* ignore */ }
writeFileSync(target, data);
}
}
export class ClaudeCodeAdapter implements TargetAdapter {
readonly name = "claude-code";
private readonly target: string;
constructor(target: string) { this.target = resolve(target); }
// --- Approach C: relax the Claude Code chat-webview CSP ----------------
// CC ships that webview with `default-src 'none'` and NO connect-src
// (getHtmlForWebview in extension.js), so the injected block's loopback
// fetch — clicks-metric, impressions, activity, debug log — is silently
// CSP-blocked. The real click-through is handled by the anchor's http(s)
// href (host-opened, CSP-exempt); this patch additionally revives the
// owned loopback for billing/telemetry by inserting a connect-src into
// that one dynamic CSP template. Sibling file (../extension.js), its own
// one-time pristine backup, byte-exact restore, idempotent — same
// reversibility contract as the index.js patch. Best-effort: a missing
// sibling / absent anchor never fails applyPatch (the href still works).
//
// NOTE (known gap): only local loopback (127.0.0.1/localhost) is opened.
// VS Code Remote/tunnels reach the loopback via an https asExternalUri
// host that this literal does not cover — out of scope for the local trial.
// The dynamic CSP template lives inside a JS template literal in
// CC's extension.js. Across CC releases the first template variable after
// `default-src 'none';` has been renamed (`${q}` in 2.1.143 → `${U}` in
// 2.1.145), so a literal anchor silently bails on every CC upgrade and
// billing telemetry quietly stops. Match the SHAPE instead — any single
// template-variable token following `default-src 'none';` — and preserve
// whatever variable was there via a capture group. The static (non-templated)
// CSP that uses `{{NONCE}}` is intentionally NOT matched here so we don't
// touch a CSP shape that's used in a different code path.
private readonly CSP_ANCHOR_RE = /default-src 'none'; (\$\{[a-zA-Z_]\w*\})/;
private readonly CSP_MARK = "connect-src http://127.0.0.1:*";
// H3 (prime-directive): only emit the ADDITIVE connect-src that the loopback
// needs (CC ships no connect-src, so there's no duplicate). We must NOT emit
// a second `img-src`: CC's own template var (e.g. `${M}` = `img-src
// ${cspSource} data:`) follows our insertion, and per CSP3 only the FIRST
// `img-src` is honored — so a narrow ad-icon `img-src` here would silently
// override CC's image policy and break CC's markdown/data:/webview images.
// The ad favicon degrades to the inline SVG "K" badge, which is harmless.
private readonly CSP_INSERT_PREFIX =
"default-src 'none'; connect-src http://127.0.0.1:* http://localhost:*; ";
private extTarget(): string {
// target = <ext>/anthropic.claude-code-X/webview/index.js
// sibling = <ext>/anthropic.claude-code-X/extension.js
return join(dirname(dirname(this.target)), "extension.js");
}
private extBackupPath(): string { return this.extTarget() + ".vibe-ads-backup"; }
/** Idempotent, reversible connect-src insertion. Never throws.
*
* Returns the patch outcome so callers (debug.log) can surface a silent
* miss — `anchor missing` historically meant a CC version bumped the
* template variable name (e.g. ${q} → ${U} between 2.1.143 and 2.1.145),
* which silently broke every webview→loopback fetch (clicks, impressions,
* view-tracking ping-back) and was only detectable by a missing-line audit
* in debug.log. */
private patchCspWithReason():
{ ok: boolean; reason?: "no-sibling" | "already" | "anchor-missing" | "io-err" } {
try {
const ext = this.extTarget();
if (!existsSync(ext)) return { ok: false, reason: "no-sibling" };
const src = readFileSync(ext, "utf8");
if (src.includes(this.CSP_MARK)) return { ok: true, reason: "already" };
const m = this.CSP_ANCHOR_RE.exec(src);
if (!m) return { ok: false, reason: "anchor-missing" };
if (!existsSync(this.extBackupPath()))
writeFileSync(this.extBackupPath(), Buffer.from(src, "utf8")); // pristine
// m[1] is the template variable token (e.g. "${q}" or "${U}"); preserve
// it so the rest of CC's CSP template renders identically.
const replaced = src.replace(this.CSP_ANCHOR_RE,
this.CSP_INSERT_PREFIX + m[1]);
writeFileSync(ext, Buffer.from(replaced, "utf8"));
return { ok: true };
} catch { return { ok: false, reason: "io-err" }; }
}
/** Back-compat shim. The href click-out never depended on this — but its
* silent failure DID hide a hard CC-upgrade regression for a release, so
* every applyPatch now also calls patchCspWithReason() and surfaces a dlog
* line (see applyPatch in this file). */
private patchCsp(): void { this.patchCspWithReason(); }
/** Prime the structural connect-src CSP relaxation WITHOUT injecting an ad
* block. See TargetAdapter.prime. Reuses the exact idempotent insertion
* applyPatch performs, so a later applyPatch is a no-op on the CSP and a
* routine restore({keepCsp:true}) keeps it. */
prime(): OpResult {
const r = this.patchCspWithReason();
try {
dlog("ext", "csp.prime", { ok: r.ok, reason: r.reason || "ok" });
} catch { /* dlog must never break prime */ }
return { ok: r.ok, reason: r.reason };
}
/** Byte-exact revert of the CSP patch from its pristine backup. Never throws. */
private restoreCsp(): void {
try {
const bak = this.extBackupPath();
if (!existsSync(bak)) return;
const pristine = readFileSync(bak);
writeFileSync(this.extTarget(), pristine);
if (sha256(readFileSync(this.extTarget())) === sha256(pristine))
rmSync(bak);
} catch { /* best-effort */ }
}
private backupPath(): string { return this.target + ".kickbacks-backup"; }
// Pre-rename installs wrote the pristine backup under earlier names; prefer
// any existing backup (new OR legacy) so we never (a) lose the real pristine
// by overwriting it with an already-patched file, nor (b) report "no backup"
// when a legacy one exists. New backups use the .kickbacks-backup name.
// W1 (this rename): .vibe-ads-backup (the Vibe-Ads era)
// pre-S3 install: .vibads-backup (the original spelling)
private legacyBackupPaths(): string[] {
return [this.target + ".vibe-ads-backup",
this.target + ".vibads-backup"];
}
private existingBackupPath(): string | null {
if (existsSync(this.backupPath())) return this.backupPath();
for (const p of this.legacyBackupPaths()) if (existsSync(p)) return p;
return null;
}
private findArray(src: string): [number, number] | null {
for (const m of src.matchAll(ARRAY_RE)) {
if (ANCHORS.some((a) => m[0].includes(a)))
return [m.index!, m.index! + m[0].length];
}
return null;
}
version(): string | null {
// Version is the parent extension dir name segment, e.g.
// .../anthropic.claude-code-2.1.143/webview/index.js — or, since CC moved to
// platform-specific packages, .../anthropic.claude-code-2.1.161-win32-x64/…
// Capture just the semver core so neither the status bar nor telemetry ever
// shows the "-win32-x64" packaging suffix. Fall back to the raw tail if the
// shape is unexpected, so a locatable dir never regresses to "unknown".
const core = /anthropic\.claude-code-(\d+\.\d+\.\d+)/.exec(this.target);
if (core) return core[1];
const loose = /anthropic\.claude-code-([0-9][^/\\]*)/.exec(this.target);
return loose ? loose[1] : "unknown";
}
/** True iff the live target currently carries our injected block. One
* cheap read; the debug reassert tick uses it so it only re-applies when
* the patch actually drifted (CC self-update / fresh load), never churning
* the loopback when already healthy. */
isPatched(): boolean {
try {
return existsSync(this.target) &&
readFileSync(this.target, "utf8").includes(BLOCK_START);
} catch {
return false;
}
}
/** Ground-truth snapshot for the diagnose command. Never throws — every
* read is guarded so the report renders even on a half-broken install. */
diagnose(): AdapterDiagnostics {
const out: AdapterDiagnostics = {
name: this.name, target: this.target, targetExists: existsSync(this.target),
version: this.version(), compatible: false, isPatched: false,
backup: { exists: false, path: null, hasArray: false, hasBlock: false },
live: { hasArray: false, bareVerbPresent: false },
};
try {
const pf = this.preflight();
out.compatible = pf.compatible;
out.reason = pf.reason;
out.isPatched = this.isPatched();
const bakPath = this.existingBackupPath();
if (bakPath) {
out.backup.exists = true;
out.backup.path = bakPath;
try {
const b = readFileSync(bakPath, "utf8");
out.backup.hasArray = this.findArray(b) !== null;
out.backup.hasBlock = b.includes(BLOCK_START);
} catch { /* leave defaults */ }
}
if (out.targetExists) {
try {
const live = readFileSync(this.target, "utf8");
out.live.hasArray = this.findArray(live) !== null;
// bare verb word present even if NOT inside a matchable array — the
// tell that distinguishes "bundle format changed" from "file stripped".
out.live.bareVerbPresent =
ANCHORS.some((a) => live.includes(a.replace(/"/g, "")));
} catch { /* leave defaults */ }
}
} catch { /* never throw */ }
return out;
}
preflight(): PreflightResult {
try {
if (!existsSync(this.target))
return { ok: true, compatible: false, version: null, reason: "target not found" };
// Compatibility = the verb array is present in EITHER the pristine backup
// OR the live file. We prefer the backup (an older Tier-0 swap could strip
// the live anchor), but a STALE/TAINTED backup — a truncated capture, or a
// crash / self-update race mid-write — must NOT dead-end activation: fall
// back to the live file. The current patch only APPENDS a block and leaves
// the verb array intact, so the live file is a safe fallback. Only when
// NEITHER source carries the array is the build genuinely incompatible.
// This breaks the "bad backup ⇒ permanent incompatible" trap, where
// applyPatch's backup recapture never runs because preflight early-returns
// first. The reason + source are logged so a field miss is diagnosable.
const bak = this.existingBackupPath();
const inBackup = bak !== null
&& this.findArray(readFileSync(bak, "utf8")) !== null;
const inLive = inBackup
|| this.findArray(readFileSync(this.target, "utf8")) !== null;
if (!inBackup && !inLive) {
try {
dlog("ext", "preflight.miss",
{ hadBackup: bak !== null, version: this.version() });
} catch { /* dlog must never break preflight */ }
return { ok: true, compatible: false, version: this.version(),
reason: bak !== null
? "verb array not found (backup+live both stale)"
: "verb array not found (incompatible build)" };
}
return { ok: true, compatible: true, version: this.version() };
} catch (e) {
return { ok: false, compatible: false, version: null, reason: String(e) };
}
}
private ensureBackup(): Buffer | null {
const existing = this.existingBackupPath();
if (existing) {
const buf = readFileSync(existing);
// Backup-integrity check. A backup is a valid pristine source only if it
// (a) does NOT already contain our injected block AND (b) actually carries
// the verb array. (a) guards a backup captured AFTER an earlier patch
// leaked in (CC self-update mid-apply race) — reusing it would compound
// the damage. (b) guards a truncated/mangled capture (the stale backup
// that makes preflight read no array and dead-end) — patching from it
// would inject into broken content and keep failing. Either defect ⇒
// delete and recapture from the live file (the taint guard below refuses
// the recapture when `target` itself is patched, so a poisoned backup is
// never re-minted from a poisoned live file).
const tainted = buf.indexOf(BLOCK_START) !== -1;
const stale = this.findArray(buf.toString("utf8")) === null;
if (tainted || stale) {
try { dlog("ext", "backup.recapture",
{ path: existing, tainted, stale }); } catch { /* ignore */ }
try { unlinkSync(existing); } catch { /* fall through */ }
} else {
return buf;
}
}
const raw = readFileSync(this.target);
// Taint guard (cross-window interleave): if the live file ALREADY carries
// our block — e.g. another window re-patched right after this window's
// restore() deleted the backup — capturing it would enshrine PATCHED bytes
// as "pristine": a later restore() would write the ad block back, pass its
// own sha check, delete the backup, and leave CC permanently patched even
// after opt-out/kill. The patch is not byte-exactly strippable (applyPatch
// collapses trailing whitespace before appending), so REFUSE to capture:
// no backup is written, applyPatch treats it as apply-success-no-write,
// and the next CC self-update delivers a fresh pristine file to capture.
if (raw.indexOf(BLOCK_START) !== -1) {
try { dlog("ext", "backup.refused",
{ reason: "live file already patched" }); } catch { /* ignore */ }
return null;
}
writeFileSync(this.backupPath(), raw);
return raw; // pristine
}
private renderBlock(p: PatchParams): string {
const assetPath = resolveBlockAsset(dirname(__filename));
let src = readFileSync(assetPath, "utf8");
const subs: Record<string, string> = {
__VIBE_ADS_TIER__: String(p.tier),
__VIBE_ADS_AD__: JSON.stringify(p.adText),
__VIBE_ADS_ICON__: JSON.stringify(p.iconRef),
__VIBE_ADS_ICON_URL__: JSON.stringify(p.iconUrl),
__VIBE_ADS_PORT__: String(p.loopbackPort),
__VIBE_ADS_LBTOKEN__: JSON.stringify(p.loopbackToken),
__VIBE_ADS_BASE__: JSON.stringify(p.loopbackBase ?? ""),
__VIBE_ADS_DEBUG__: p.debug ? "true" : "false",
__VIBE_ADS_CLICKTOKEN__: JSON.stringify(p.clickToken),
__VIBE_ADS_CLICKURL__: JSON.stringify(p.clickUrl),
__VIBE_ADS_CORR__: JSON.stringify(p.corr),
// Mirror-of-spinner usage-banner ad gate (spec §4.1).
__VIBE_ADS_BANNER_ON__: p.bannerOn ? "true" : "false",
// W3: server-authoritative visible-time threshold (15 s default).
__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.trim();
}
applyPatch(p: PatchParams): OpResult {
try {
if (!existsSync(this.target)) return { ok: false, reason: "target not found" };
const pristineBuf = this.ensureBackup();
// Taint-guard refusal: target already patched and no pristine backup
// exists to strip-and-reapply from. Do NOT write and do NOT capture —
// serving continues off the on-disk block until a CC self-update
// supplies a fresh pristine file. See ensureBackup.
if (pristineBuf === null)
return { ok: true, reason: "already patched; no pristine backup" };
const pristine = pristineBuf.toString("utf8");
// We still call findArray as a compatibility GATE — if the verb
// array literal isn't where we expect, this CC build's spinner
// layout has changed and our block's DOM selectors are likely
// stale too. Refuse rather than silently inject into a build
// we can't reliably target.
if (this.findArray(pristine) === null)
return { ok: false, reason: "verb array not found" };
// We used to ALSO collapse the verb array to `[adText, ""]` so
// a degraded run (block fails to load) would at least flash the
// ad as plain text in the spinner. That backfired: it MASKED
// block.desync — the user couldn't tell when our overlay
// failed because the underlying spinner just rendered the ad
// text directly. Leaving the array intact means a failed block
// shows CC's normal "Discombobulating…" / "Baking…" verbs, a
// clear signal that something is wrong. The overlay still
// covers the verb via DOM when the block runs.
let out = pristine.replace(BLOCK_RE, "").replace(/\s+$/, "");
out = out + "\n" + this.renderBlock(p) + "\n";
const outBuf = Buffer.from(out, "utf8");
if (sha256(outBuf) !== sha256(readFileSync(this.target)))
atomicWriteFile(this.target, outBuf);
// approach C: revive the loopback (best-effort). Surface the outcome to
// debug.log so a future CC CSP-template rename can't silently kill
// billing telemetry again (see CSP_ANCHOR_RE comment).
const cspResult = this.patchCspWithReason();
try {
dlog("ext", "csp.patch",
{ ok: cspResult.ok, reason: cspResult.reason || "ok" });
} catch { /* dlog must never break applyPatch */ }
return { ok: true };
} catch (e) {
return { ok: false, reason: String(e) };
}
}
// `keepCsp` keeps the sibling extension.js connect-src relaxation in place
// while still byte-exact reverting the VISIBLE verb/block change in index.js.
// Routine deactivate() passes this: Claude Code captures its webview CSP
// template into memory at extension-host load (before our re-patch), so a
// CSP reverted on every deactivate would NEVER be effective for the running
// session and loopback telemetry (billing) stays CSP-blocked forever. The
// relaxation is invisible and loopback-scoped; explicit restore / kill-
// switch / sign-out still pass no opts and fully revert it (prime directive
// for any user-initiated teardown).
restore(opts?: { keepCsp?: boolean }): RestoreResult {
try {
const bak = this.existingBackupPath();
if (bak === null) {
// No visible-block backup — e.g. prime() relaxed the CSP on boot but
// an ad never arrived to applyPatch a block. An explicit restore (no
// keepCsp: kill-switch / sign-out / "Restore Claude Code") must STILL
// revert the primed sibling CSP, or a primed-but-never-patched install
// would leave CC's extension.js modified with no way back. Mirrors the
// codex adapter's restore() (prime directive: opt-out fully reverts).
if (!opts?.keepCsp) this.restoreCsp();
return { ok: true, restored: false, reason: "no backup present" };
}
const pristine = readFileSync(bak);
// Taint guard: a backup captured from an already-patched live file (a
// pre-guard ensureBackup could mint one) carries our block — writing it
// verbatim would REINSTATE the ad, pass the sha check below, and delete
// the only backup. Strip our own block first so restore always removes
// the ad (the stripped bytes are the closest-to-pristine we hold).
let out = pristine;
if (pristine.indexOf(BLOCK_START) !== -1) {
try { dlog("ext", "restore.strip-tainted-backup", { path: bak }); }
catch { /* ignore */ }
out = Buffer.from(
pristine.toString("utf8").replace(BLOCK_RE, ""), "utf8");
}
writeFileSync(this.target, out);
const now = sha256(readFileSync(this.target));
if (now !== sha256(out))
return { ok: false, restored: false, reason: "sha256 mismatch after restore" };
rmSync(bak);
if (!opts?.keepCsp) this.restoreCsp(); // approach C: revert sibling CSP
return { ok: true, restored: true };
} catch (e) {
return { ok: false, restored: false, reason: String(e) };
}
}
}