← back to Kickbacks Ai Upstream
src/activation/webviewInjection.ts
425 lines
import type * as vscode from "vscode";
import type { TargetAdapter, PatchParams } from "../adapters/types";
import type { AuthClient } from "../auth/client";
import type { DebugController } from "../debug";
import type { SessionState } from "../sessionState";
import type { PatchAd, PortfolioResponse } from "../portfolio/client";
import type { PortfolioClient } from "../portfolio/client";
import { newMetricEventUuid, type MetricsClient } from "../metrics/client";
import type { LogTail } from "../activity/logTail";
import type { TestHooks } from "../testHooks";
import type { ActivationContext } from "./context";
import type { SbState } from "../statusbar";
import { Loopback } from "../loopback";
import { bootLoopback } from "../util/loopbackBoot";
import { dlog, debugEnabled } from "../log";
import { errMsg } from "../util/errMsg";
import { resolveBannerOn } from "../banner";
import { webviewMode, bannerOverride } from "../modes";
import { ImpressionDedupe } from "../metrics/dedupe";
import { shouldReassert } from "../reassert";
import { canPatch, canServeAds, servingVerdict } from "../servingGate";
import type { DesyncState } from "./desyncDetector";
import { setupAdRotation, type AdRotationDeps } from "./adRotation";
/** Anti-misclick floor: a click within the first CLICK_THRESHOLD_MS of
* cumulative ad visibility is logged but NOT forwarded to the metrics
* ledger. 15s per product call. */
const CLICK_THRESHOLD_MS = 15_000;
/** Status decision for an injection cycle that can't (re)apply this tick — a
* loopback port race (EADDRINUSE on reload/self-update) or a single applyPatch
* failure. A live, already-patched block is STILL serving the ad, so it must
* not be relabeled "incompatible": return `null` to defer to the active /
* earnings state. `isPatched` is "ANY webview target is patched" — a serving
* Codex block counts exactly like a serving Claude Code block (S9 dual
* target). Only a genuinely un-patched install yields an incompatible
* status. Pure + exported so the honest-label contract is unit-tested without
* standing up the loopback. */
export function applyMissStatus(
isPatched: boolean, ccVersion: string,
): SbState | null {
return isPatched ? null : { kind: "incompatible", version: ccVersion };
}
export interface WebviewInjectionDeps {
ctx: vscode.ExtensionContext;
actx: ActivationContext;
adapter: TargetAdapter;
auth: AuthClient;
debugCtl: DebugController;
session: SessionState;
portfolio: PortfolioClient;
metrics: MetricsClient;
logTail: LogTail;
testHooks: TestHooks;
statusBar: { set: (s: SbState) => void };
ccVersion: string;
killed: boolean;
/** Mutated by the caller when the kill-switch state changes. */
killedRef: { current: boolean };
/** Mutable ref for the outer `ad` variable. */
adRef: { current: PatchAd | null };
portfolioResp: PortfolioResponse | null;
viewThresholdMs: number;
statusBarShowActive: () => Promise<void>;
scheduleEarningsRefresh: () => void;
desyncState: DesyncState;
/** Whether the Claude Code target preflighted compatible at activation.
* False on a codex-only boot: the Claude applyPatch/reassert writers are
* skipped (they'd fail anchor validation every tick anyway) and Codex is
* the serving surface. Omitted ⇒ true (legacy callers/tests keep the
* Claude-primary semantics). */
claudeCompatible?: boolean;
/** Forwarded to ad rotation: fired after each ad apply so the CLI surface can
* re-sync immediately (sign-in swap / rotation) instead of waiting 60s. */
onAdApplied?: () => void;
}
export interface WebviewInjectionResult {
lbInfo: { port: number; base: string } | null;
reapplyCodex: (() => void) | null;
/** Production-path "hard" reassert: restore + re-applyPatch so the file's
* identity changes and VS Code re-evaluates a stale-cached webview module.
* Health-gated, guarded, never throws. Null when injection didn't set up
* (no ad / killed / loopback unavailable). Used by the desync watchdog. */
cycleReassert: (() => void) | null;
/** Force an immediate portfolio refresh. Used by the sign-in handler to swap
* the signed-out DEMO ad for a real, user-crediting one (and re-point the
* overlay's billing token) without waiting for the 60s rotation timer. Null
* when injection didn't set up (no ad at activation / killed). */
refreshPortfolioNow: ((force?: boolean) => Promise<void>) | null;
}
/** Boot the loopback, patch the webview, set up reassert + ad rotation.
* Returns the loopback info and a Codex reapply function. */
export async function setupWebviewInjection(
deps: WebviewInjectionDeps,
): Promise<WebviewInjectionResult> {
const {
ctx, actx, adapter, auth, debugCtl, session, portfolio,
metrics, logTail, testHooks, statusBar, ccVersion, killedRef, adRef,
portfolioResp, viewThresholdMs, statusBarShowActive,
scheduleEarningsRefresh, desyncState,
} = deps;
const ad = adRef.current;
if (!ad || deps.killed || webviewMode() !== "on") {
return { lbInfo: null, reapplyCodex: null, cycleReassert: null,
refreshPortfolioNow: null };
}
// Stable snapshot of the activation-time ad.
let activeAd = ad;
let corr = activeAd.adId + "." + Math.random().toString(36).slice(2, 8);
const impDedupe = new ImpressionDedupe();
// Rotation/poll-lag attribution (audit #17): a rotation flips activeAd
// instantly, but the webview only learns of it from its own 10s /ad poll —
// so for up to 10s it keeps emitting the OLD ad's view events (and clicks),
// which used to be stamped with the NEW ad's adId/campaignId/sessionToken
// at arrival time (cross-campaign misattribution on every rotation
// boundary). Keep a small registry of recently-served ads, lazily refreshed
// from the live activeAd on every event/click, and attribute by the ad the
// webview CLAIMS (its `ad=` param) when we recently served it. The deployed
// block keys its view sessions by ad TEXT (viewShow(AD, …)) and sends that
// as `ad=`, so claims resolve by adId OR adText. A ~10s-stale session token
// is fine — the server TTL is 300s. Unknown/absent claims fall back to
// activeAd, exactly the pre-fix behavior.
const RECENT_ADS_MAX = 8;
type AdAttribution =
{ adId: string; campaignId: string; sessionToken: string;
demo?: boolean };
const recentAds = new Map<string, AdAttribution & { adText: string }>();
const resolveAttribution = (claimed?: string): AdAttribution => {
const live = activeAd;
if (live) {
// Delete + re-set: bump recency and adopt a refreshed session token.
// The demo stamp travels with the token (BL-187 contract): a registry
// entry minted while signed out holds a demo:<client_id>-namespace
// token, and the namespace guard below needs to see that.
recentAds.delete(live.adId);
recentAds.set(live.adId, { adId: live.adId,
campaignId: live.campaignId, sessionToken: live.sessionToken,
demo: live.demo, adText: live.adText });
while (recentAds.size > RECENT_ADS_MAX) {
recentAds.delete(recentAds.keys().next().value as string);
}
}
if (claimed) {
const byId = recentAds.get(claimed);
if (byId) return byId;
let byText: AdAttribution | undefined;
for (const e of recentAds.values()) { // last match = most recent
if (e.adText === claimed) byText = e;
}
if (byText) return byText;
}
return live;
};
// Token-namespace guard (2026-06-11): MetricsClient routes by LIVE auth
// state (token present → /v1/metrics, absent → /v1/metrics/demo), but the
// attribution above rides whatever session token the registry holds. A
// demo-era token POSTed on the authed route (or a real token on the demo
// route, after a sign-out) binds to the WRONG uid namespace and the server
// rejects it 403 "invalid or expired session_token" — every tick, for the
// life of the stale entry; in prod this showed as a chronic all-403 stream
// of full view sessions. The mismatch is decidable client-side with no
// clock assumptions: bill only when the token's namespace matches the
// route the send will take. Mismatched events are dropped with a dlog —
// they were 100%-guaranteed server rejects, so this loses no revenue.
const namespaceMismatch = (attr: AdAttribution): boolean =>
!!attr.demo === !!auth.accessToken();
const codexAdapter = actx.codexAdapter;
actx.loopback = new Loopback({
onEvent: (k, payload) => {
// Billing gate (wave 2, audit #3): the webview's pollAd ignores the
// empty /ad payload (it only adopts a NEW ad), so an already-running
// overlay keeps emitting view events straight through a confirmed kill
// or a deliberate disable. The extension side is the billing authority:
// drop the forwarding here so a stale overlay can render but never bill.
if (!canServeAds()) {
dlog("ext", "metric.gated", { event: k, verdict: servingVerdict() },
{ corr });
return;
}
// Audit #17: bill the ad the webview claims (post-rotation poll lag),
// not necessarily the current activeAd — see resolveAttribution above.
const attr = resolveAttribution(payload.claimedAdId);
if (namespaceMismatch(attr)) {
dlog("ext", "metric.namespace_drop",
{ event: k, adId: attr.adId, demo: !!attr.demo,
authed: !!auth.accessToken() }, { corr });
return;
}
const eventUuid = payload.eventUuid || newMetricEventUuid();
if (k !== "view_tick" && k !== "error_impression"
&& !impDedupe.shouldSend(k, attr.adId, payload.surface)) {
dlog("ext", "metric.deduped",
{ event: k, surface: payload.surface, eventUuid }, { corr });
return;
}
dlog("ext", "metric.send", { event: k, adId: attr.adId,
surface: payload.surface, visibleMs: payload.visibleMs, eventUuid },
{ corr });
metrics.send(k, {
adId: attr.adId,
campaignId: attr.campaignId,
ccVersion,
corr,
sessionToken: attr.sessionToken,
...payload,
eventUuid,
});
if (k === "view_threshold_met" || k === "impression_viewable"
|| k === "error_impression") {
scheduleEarningsRefresh();
}
},
onClick: (_ct, surface, visibleMs, eventUuidFromLoopback, claimedAdId) => {
// Same billing gate as onEvent: a click on a stale (gated) overlay
// still opens the advertiser URL webview-side, but is never billed.
if (!canServeAds()) {
dlog("ext", "metric.gated", { event: "click",
verdict: servingVerdict() }, { corr });
return;
}
// Audit #17: a click on the OLD ad's anchor during the /ad poll lag
// must bill the OLD campaign/token, not the freshly-rotated one.
const attr = resolveAttribution(claimedAdId);
if (namespaceMismatch(attr)) {
dlog("ext", "metric.namespace_drop",
{ event: "click", adId: attr.adId, demo: !!attr.demo,
authed: !!auth.accessToken() }, { corr });
return;
}
const eventUuid = eventUuidFromLoopback || newMetricEventUuid();
if (typeof visibleMs === "number" && visibleMs < CLICK_THRESHOLD_MS) {
dlog("ext", "metric.click.early", { adId: attr.adId,
surface, visibleMs, thresholdMs: CLICK_THRESHOLD_MS, eventUuid },
{ corr });
return;
}
dlog("ext", "metric.send", { event: "click", adId: attr.adId,
surface, visibleMs, eventUuid }, { corr });
metrics.send("click", { adId: attr.adId, campaignId: attr.campaignId,
ccVersion, corr, sessionToken: attr.sessionToken,
eventUuid, ...(surface ? { surface } : {}) });
scheduleEarningsRefresh();
},
getActivity: () => logTail.current() ?? {},
// Gate /ad (wave 2, audit #3): a confirmed kill or a deliberate disable
// stops handing out NEW ads. NOTE: an already-shown overlay is NOT
// dropped by this — pollAd ignores the empty payload and keeps the last
// creative painted until idle/reload. That is why onEvent/onClick above
// are also gated: the stale overlay can render but never bill.
// /activity and /log relay stay untouched.
getCurrentAd: () => activeAd && canServeAds() ? {
adText: activeAd.adText, clickUrl: activeAd.clickUrl,
iconUrl: activeAd.iconUrl, adId: activeAd.adId,
campaignId: activeAd.campaignId,
} : null,
onTestRoute: (n, p) => testHooks.handleTestRoute(n, p),
onWebviewLog: (raw) => {
try {
if (raw.includes('"block.start"') || raw.includes("block.start")) {
desyncState.lastBlockStartAt = Date.now();
}
} catch { /* best-effort */ }
},
});
const { port, token, base: lbBase } = await bootLoopback(actx.loopback, ctx);
const lbInfo = { port, base: lbBase };
dlog("ext", "loopback", { port, base: lbBase });
let patchParams: PatchParams = {
tier: 3, adText: activeAd.adText, iconRef: activeAd.iconRef,
iconUrl: activeAd.iconUrl, clickToken: "ck", clickUrl: activeAd.clickUrl,
corr, loopbackPort: port,
loopbackToken: token, loopbackBase: lbBase, debug: debugEnabled(),
bannerOn: resolveBannerOn(activeAd.bannerEnabled === true, bannerOverride()),
viewThresholdMs,
};
const claudeCompatible = deps.claudeCompatible ?? true;
// Honest status: a transient miss THIS cycle (a loopback port race on
// reload/self-update, or a single applyPatch failure) must NOT clobber a
// still-live, ad-serving block to a scary "incompatible" label. EITHER
// target counts — a serving Codex block keeps the label honest exactly
// like a serving Claude Code block.
const anyTargetPatched = (): boolean => {
try { if (adapter.isPatched?.() === true) return true; }
catch { /* fall through to codex */ }
try { return codexAdapter?.isPatched?.() === true; }
catch { return false; }
};
const setIncompatibleUnlessPatched = (): void => {
const s = applyMissStatus(anyTargetPatched(), ccVersion);
if (s) statusBar.set(s);
else void statusBarShowActive();
};
if (port < 0) {
// EADDRINUSE / port-exhaustion: skip the apply (but keep an existing patch's
// status honest — a stale loopback from the prior host doesn't mean the file
// is unpatched).
setIncompatibleUnlessPatched();
dlog("ext", "loopback.unavailable",
{ port, patched: adapter.isPatched?.() === true });
return { lbInfo, reapplyCodex: null, cycleReassert: null,
refreshPortfolioNow: null };
}
// Serving gate (wave 2, audit #14/#19): a crash-canary suspension or a
// persisted kill must also stop THIS production-path write — pre-fix only
// the bootCanary's own debug-path calls were skipped, and the activation
// path re-patched seconds after the "skipping automatic patch" toast.
// A codex-only boot (claude incompatible) skips the doomed Claude write
// entirely: it would fail anchor validation and clobber the status bar.
if (!canPatch()) {
dlog("ext", "applyPatch.skip", { gate: servingVerdict() });
} else if (!claudeCompatible) {
dlog("ext", "applyPatch.skip", { reason: "claude-incompatible" });
} else {
const res = adapter.applyPatch(patchParams);
dlog("ext", "applyPatch", { ok: res.ok, reason: res.reason });
if (res.ok) {
desyncState.lastApplyAt = Date.now();
void statusBarShowActive();
}
else setIncompatibleUnlessPatched();
}
// S9: patch Codex with the SAME ad/loopback params. A Codex success drives
// the "active" status bar (it IS the serving surface on a codex-only boot)
// but must NEVER set desyncState.lastApplyAt — that would arm the CLAUDE
// webview-cache desync watchdog (cycle → reload → toast ladder) with no
// Claude apply to heal; lastApplyAt === 0 keeps it deliberately passive
// (reassert.ts::desyncDecision "no-apply").
const applyCodex = (): void => {
if (!codexAdapter) return;
if (!canPatch()) { dlog("ext", "codex.skip", { reason: "serving-gate" }); return; }
if (port < 0) { dlog("ext", "codex.skip", { reason: "no-loopback" }); return; }
try {
const cpf = codexAdapter.preflight();
if (!cpf.compatible) {
dlog("ext", "codex.skip", { reason: cpf.reason });
return;
}
const cr = codexAdapter.applyPatch(patchParams);
dlog("ext", "codex.applyPatch", { ok: cr.ok, reason: cr.reason });
if (cr.ok) void statusBarShowActive();
} catch (e) {
dlog("ext", "codex.error", { msg: errMsg(e) });
}
};
const reapplyCodex = applyCodex;
// Codex-only boot: apply NOW — the deferred call below would leave the
// first 10s of the session unserved (and the status bar unconfirmed).
// Idempotent: the 10s pass re-validates via isPatched/marker checks.
if (!claudeCompatible) applyCodex();
actx.timers.push(setTimeout(applyCodex, 10_000));
// Reassert the injection on a timer. The Claude branch is gated on the
// boot-time compatibility flag: a codex-only boot would otherwise retry a
// doomed anchor-validation failure every 60s (a mid-session CC install
// lands in a NEW versioned directory this adapter's fixed target can't
// see — only a reload re-preflights, same as today).
const reassertWebview = (): void => {
try {
// canPatch() folds in the serving gate (kill posture incl. the offline
// freeze, master toggle, canary suspension) — wave 2, audit #4/#9.
if (!canPatch() || !shouldReassert({
haveAd: !!adRef.current, killed: killedRef.current })) return;
if (claudeCompatible && adapter.isPatched?.() !== true) {
const r = adapter.applyPatch(patchParams);
if (!r.ok) dlog("ext", "reassert.skip", { reason: r.reason });
}
if (codexAdapter && codexAdapter.isPatched?.() !== true) {
applyCodex();
}
} catch { /* prime directive: never break activation */ }
};
actx.timers.push(setInterval(reassertWebview, 60_000));
// "Hard" reassert for the webview-cache desync (file is patched but the
// webview cached the pre-patch module, so isPatched()-gated reasserts can't
// see it). restore() + applyPatch() changes the file's identity, nudging VS
// Code to re-evaluate the module. Health-gated like reassertWebview; only
// ever invoked by the desync watchdog after sustained, CC-active silence.
const cycleReassert = (): void => {
try {
// Same gate as reassertWebview: the restore+re-apply cycle must never
// fire on a killed / frozen / disabled / suspended install (wave 2).
if (!canPatch() || !shouldReassert({
haveAd: !!adRef.current, killed: killedRef.current })) return;
if (claudeCompatible) {
adapter.restore();
const r = adapter.applyPatch(patchParams);
if (r.ok) desyncState.lastApplyAt = Date.now();
dlog("ext", "reassert.cycle", { ok: r.ok, reason: r.reason });
}
applyCodex();
} catch { /* prime directive: never break activation */ }
};
// Ad rotation subsystem.
const rotation = setupAdRotation({
adapter, portfolio, auth, debugCtl, session, ccVersion, port,
patchParams,
activeAdRef: { get current() { return activeAd; }, set current(v) { activeAd = v; } },
corrRef: { get current() { return corr; }, set current(v) { corr = v; } },
adRef,
impDedupe,
reapplyCodex,
onAdApplied: deps.onAdApplied,
timers: actx.timers,
} as AdRotationDeps, portfolioResp);
return { lbInfo, reapplyCodex, cycleReassert,
refreshPortfolioNow: rotation.refreshNow };
}