Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Added

- Browser: persist thinking-effort selection evidence as `browser.thinkingSelection`, parallel to `browser.modelSelection`, and render it in `oracle status`. `ensureThinkingTime` verified the requested tier and then discarded the result, and the model evidence cannot stand in: for a Pro-capable target the picker reports the requested model string as the resolved label, so `resolvedLabel === requestedModel; verified: true` is byte-identical whether or not the Pro row was ever selected. `verified` is true only for the two statuses that positively observed the option's selected state, and strict (fail-closed) requests still throw before submit on every other outcome, so a persisted strict record is proof by refusal that no degraded tier was used.

### Fixed

- Remote: stop stripping run identity and selection evidence from bridged results. `sanitizeResult` correctly keeps host detail (pids, ports, profile paths) from crossing to a client on another machine, but the whitelist had drifted narrower than that rule and also dropped `modelSelection`, `thinkingSelection`, `archive`, `tabUrl`, `conversationId`, and `promptSubmitted` — so a remote caller could not tell which model or effort answered their prompt, nor bind the answer to a durable ChatGPT URL. The fields are optional, so nothing failed; the answer simply arrived unattributable.

## 0.18.0 — 2026-08-14

### Changed
Expand Down
35 changes: 29 additions & 6 deletions src/browser/actions/thinkingTime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ChromeClient, BrowserLogger } from "../types.js";
import type { ThinkingTimeLevel } from "../../oracle/types.js";
import type {
BrowserThinkingSelectionEvidence,
BrowserThinkingSelectionStatus,
} from "../../sessionManager.js";
import {
MENU_CONTAINER_SELECTOR,
MENU_ITEM_SELECTOR,
Expand Down Expand Up @@ -103,13 +107,18 @@ function logPickerDiagnostic(result: ThinkingTimeOutcome | undefined, logger: Br
*
* Missing controls remain best-effort except Pro Extended, which fails closed
* unless the selected option is confirmed.
*
* Returns the evidence record for what was actually confirmed in the picker, so
* a caller can persist proof that a run submitted at the requested effort rather
* than inheriting whatever tier the composer already had. Strict (fail-closed)
* requests never return an unverified record — they throw before submit.
*/
export async function ensureThinkingTime(
Runtime: ChromeClient["Runtime"],
level: ThinkingTimeLevel,
logger: BrowserLogger,
desiredModel?: string | null,
) {
): Promise<BrowserThinkingSelectionEvidence> {
const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
const targetModelKind = inferThinkingTargetModelKind(desiredModel);
Expand All @@ -120,14 +129,28 @@ export async function ensureThinkingTime(
const strictProEffort =
level === "pro" ||
((targetModelKind === "pro" || observedModelKind === "pro") && level === "extended");
const evidence = (
status: BrowserThinkingSelectionStatus,
resolvedLabel: string | null,
): BrowserThinkingSelectionEvidence => ({
requestedLevel: level,
status,
resolvedLabel,
verified: status === "already-selected" || status === "switched",
strictFailClosed: strictProEffort,
targetModelKind: targetModelKind ?? null,
observedModelKind: observedModelKind ?? null,
source: "chatgpt-thinking-picker",
capturedAt: new Date().toISOString(),
});

switch (result?.status) {
case "already-selected":
logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`));
return;
return evidence("already-selected", result.label ?? null);
case "switched":
logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
return;
return evidence("switched", result.label ?? null);
case "option-disabled": {
await logDomFailure(Runtime, logger, "thinking-option-disabled");
logPickerDiagnostic(result, logger);
Expand All @@ -148,7 +171,7 @@ export async function ensureThinkingTime(
`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`,
),
);
return;
return evidence("unverified", result.label ?? null);
}
case "chip-not-found":
case "menu-not-found":
Expand Down Expand Up @@ -176,7 +199,7 @@ export async function ensureThinkingTime(
? "the effort in ChatGPT is unconfirmed"
: "keeping the effort already selected in ChatGPT";
logger(formatBrowserThinkingLog(`${message}; ${outcome}.`));
return;
return evidence("unverified", null);
}
default: {
await logDomFailure(Runtime, logger, "thinking-time-unknown");
Expand All @@ -192,7 +215,7 @@ export async function ensureThinkingTime(
`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`,
),
);
return;
return evidence("unverified", null);
}
}
}
Expand Down
15 changes: 12 additions & 3 deletions src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ import {
} from "./actions/deepResearch.js";
import { estimateTokenCount, withRetries, delay } from "./utils.js";
import { formatElapsed } from "../oracle/format.js";
import type { BrowserModelSelectionEvidence } from "../sessionStore.js";
import type {
BrowserModelSelectionEvidence,
BrowserThinkingSelectionEvidence,
} from "../sessionStore.js";
import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY } from "./constants.js";
import type { LaunchedChrome } from "chrome-launcher";
import { BrowserAutomationError } from "../oracle/errors.js";
Expand Down Expand Up @@ -951,6 +954,7 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse
let lastUrl: string | undefined;
let promptSubmitted = false;
let modelSelectionEvidence: BrowserModelSelectionEvidence | undefined;
let thinkingSelectionEvidence: BrowserThinkingSelectionEvidence | undefined;
let tabLease: BrowserTabLease | null = null;
let conversationUrlMonitor: ConversationUrlMonitor | null = null;
const emitRuntimeHint = async (): Promise<void> => {
Expand Down Expand Up @@ -1507,7 +1511,7 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse
const thinkingTime = config.thinkingTime;
if (thinkingTime && !deepResearch) {
const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
await raceWithDisconnect(
thinkingSelectionEvidence = await raceWithDisconnect(
withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), {
retries: 2,
delayMs: 300,
Expand Down Expand Up @@ -1752,6 +1756,7 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse
artifacts: savedArtifacts,
archive,
modelSelection: modelSelectionEvidence,
thinkingSelection: thinkingSelectionEvidence,
tookMs: durationMs,
answerTokens: tokens,
answerChars: researchResult.text.length,
Expand Down Expand Up @@ -2268,6 +2273,7 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse
savedFiles: fileArtifacts.savedFiles,
archive,
modelSelection: modelSelectionEvidence,
thinkingSelection: thinkingSelectionEvidence,
tookMs: durationMs,
answerTokens,
answerChars,
Expand Down Expand Up @@ -2889,6 +2895,7 @@ async function runRemoteBrowserMode(
let lastUrl: string | undefined;
let promptSubmitted = false;
let modelSelectionEvidence: BrowserModelSelectionEvidence | undefined;
let thinkingSelectionEvidence: BrowserThinkingSelectionEvidence | undefined;
let attachedExistingTab = false;
let ownsTarget = true;
let conversationUrlMonitor: ConversationUrlMonitor | null = null;
Expand Down Expand Up @@ -3111,7 +3118,7 @@ async function runRemoteBrowserMode(
const thinkingTime = config.thinkingTime;
if (thinkingTime && !deepResearch) {
const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
await withRetries(
thinkingSelectionEvidence = await withRetries(
() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel),
{
retries: 2,
Expand Down Expand Up @@ -3292,6 +3299,7 @@ async function runRemoteBrowserMode(
artifacts: savedArtifacts,
archive,
modelSelection: modelSelectionEvidence,
thinkingSelection: thinkingSelectionEvidence,
tookMs: durationMs,
answerTokens: tokens,
answerChars: researchResult.text.length,
Expand Down Expand Up @@ -3772,6 +3780,7 @@ async function runRemoteBrowserMode(
savedFiles: fileArtifacts.savedFiles,
archive,
modelSelection: modelSelectionEvidence,
thinkingSelection: thinkingSelectionEvidence,
controllerPid: process.pid,
};
} catch (error) {
Expand Down
25 changes: 24 additions & 1 deletion src/browser/modelDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { BrowserModelSelectionEvidence, SessionMetadata } from "../sessionStore.js";
import type {
BrowserModelSelectionEvidence,
BrowserThinkingSelectionEvidence,
SessionMetadata,
} from "../sessionStore.js";
import type { BrowserModelStrategy } from "./types.js";

interface BrowserModelDisplayInput {
Expand Down Expand Up @@ -100,3 +104,22 @@ export function formatBrowserModelSelectionEvidence(
const verified = evidence.verified ? "yes" : "no";
return `requestedKey=${requestedKey}; target=${target}; resolvedLabel=${resolvedLabel}; status=${evidence.status}; strategy=${strategy}; verified=${verified}; source=${evidence.source}; capturedAt=${evidence.capturedAt}`;
}

/**
* Renders thinking-effort evidence. Kept beside the model-selection formatter
* because the two answer different questions: the model formatter says WHICH
* model the picker resolved, this one says whether the requested effort tier was
* actually confirmed before submit. For Pro-capable models the model evidence
* cannot answer the second question — its resolvedLabel is the requested model
* string either way.
*/
export function formatBrowserThinkingSelectionEvidence(
evidence: BrowserThinkingSelectionEvidence,
): string {
const resolvedLabel = cleanLabel(evidence.resolvedLabel) ?? "(none)";
const verified = evidence.verified ? "yes" : "no";
const failClosed = evidence.strictFailClosed ? "yes" : "no";
const targetKind = evidence.targetModelKind ?? "(none)";
const observedKind = evidence.observedModelKind ?? "(none)";
return `requestedLevel=${evidence.requestedLevel}; status=${evidence.status}; resolvedLabel=${resolvedLabel}; verified=${verified}; failClosed=${failClosed}; targetModelKind=${targetKind}; observedModelKind=${observedKind}; source=${evidence.source}; capturedAt=${evidence.capturedAt}`;
}
10 changes: 10 additions & 0 deletions src/browser/sessionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { formatTokenCount } from "../oracle/runUtils.js";
import { formatFinishLine } from "../oracle/finishLine.js";
import type {
BrowserModelSelectionEvidence,
BrowserThinkingSelectionEvidence,
BrowserRunWarning,
BrowserSessionConfig,
BrowserRuntimeMetadata,
Expand All @@ -21,6 +22,7 @@ import {
} from "./artifacts.js";
import {
formatBrowserModelSelectionEvidence,
formatBrowserThinkingSelectionEvidence,
formatBrowserModelTarget,
resolveBrowserModelDisplayName,
} from "./modelDisplay.js";
Expand All @@ -36,6 +38,7 @@ export interface BrowserExecutionResult {
runtime: BrowserRuntimeMetadata;
archive?: BrowserArchiveResult;
modelSelection?: BrowserModelSelectionEvidence;
thinkingSelection?: BrowserThinkingSelectionEvidence;
warnings?: BrowserRunWarning[];
answerText: string;
artifacts?: SessionArtifact[];
Expand Down Expand Up @@ -246,6 +249,12 @@ export async function runBrowserSessionExecution(
`[browser] Model selection evidence: ${formatBrowserModelSelectionEvidence(modelSelection, runOptions.model)}`,
);
}
const thinkingSelection = browserResult.thinkingSelection;
if (thinkingSelection) {
log(
`[browser] Thinking effort evidence: ${formatBrowserThinkingSelectionEvidence(thinkingSelection)}`,
);
}
const warnings = buildBrowserRunWarnings({
runOptions,
browserConfig,
Expand Down Expand Up @@ -321,6 +330,7 @@ export async function runBrowserSessionExecution(
},
archive: browserResult.archive,
modelSelection,
thinkingSelection,
warnings,
answerText,
artifacts: savedArtifacts,
Expand Down
2 changes: 2 additions & 0 deletions src/browser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type CDP from "chrome-remote-interface";
import type Protocol from "devtools-protocol";
import type {
BrowserModelSelectionEvidence,
BrowserThinkingSelectionEvidence,
BrowserRunWarning,
BrowserRuntimeMetadata,
} from "../sessionStore.js";
Expand Down Expand Up @@ -173,6 +174,7 @@ export interface BrowserRunResult {
savedFiles?: SavedBrowserFile[];
archive?: BrowserArchiveResult;
modelSelection?: BrowserModelSelectionEvidence;
thinkingSelection?: BrowserThinkingSelectionEvidence;
warnings?: BrowserRunWarning[];
tookMs: number;
answerTokens: number;
Expand Down
12 changes: 11 additions & 1 deletion src/cli/sessionDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { formatSessionExecutionLabel } from "./sessionLifecycle.js";
import {
formatBrowserModelSelectionEvidence,
formatBrowserThinkingSelectionEvidence,
formatSessionBrowserModelWithRequestedKey,
resolveSessionBrowserModelDisplayName,
} from "../browser/modelDisplay.js";
Expand Down Expand Up @@ -373,6 +374,7 @@ export async function attachSession(
config: metadata.browser?.config,
runtime,
modelSelection: metadata.browser?.modelSelection,
thinkingSelection: metadata.browser?.thinkingSelection,
warnings: metadata.browser?.warnings,
},
artifacts,
Expand Down Expand Up @@ -743,14 +745,22 @@ export function formatUserErrorMetadata(metadata?: SessionUserErrorMetadata): st

export function formatBrowserEvidence(metadata: SessionMetadata): string[] | null {
const browser = metadata.browser;
if (!browser?.modelSelection && (!browser?.warnings || browser.warnings.length === 0)) {
if (
!browser?.modelSelection &&
!browser?.thinkingSelection &&
(!browser?.warnings || browser.warnings.length === 0)
) {
return null;
}
const lines: string[] = [];
const evidence = browser.modelSelection;
if (evidence) {
lines.push(`model ${formatBrowserModelSelectionEvidence(evidence, metadata.model)}`);
}
const thinkingEvidence = browser.thinkingSelection;
if (thinkingEvidence) {
lines.push(`effort ${formatBrowserThinkingSelectionEvidence(thinkingEvidence)}`);
}
for (const warning of browser.warnings ?? []) {
lines.push(`warning ${warning.code}: ${warning.message}`);
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/sessionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export async function performSessionRun({
runtime: result.runtime,
archive: result.archive,
modelSelection: result.modelSelection,
thinkingSelection: result.thinkingSelection,
warnings: result.warnings,
},
artifacts: mergeArtifacts(sessionMeta.artifacts, result.artifacts),
Expand Down
17 changes: 17 additions & 0 deletions src/remote/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,17 @@ function sanitizeName(raw: string): string {
return raw.replace(/[^a-zA-Z0-9._-]/g, "_");
}

/**
* Whitelist rather than blacklist: a bridged result must never carry host detail
* (pids, ports, profile paths) to a client on another machine.
*
* The fields below are on the safe side of that line and are load-bearing for the
* caller. Selection evidence is the caller's only proof of WHICH model and effort
* answered their prompt — dropping it left a remote run indistinguishable from one
* that silently inherited whatever the composer had selected. The conversation
* identity is what binds an answer to a durable ChatGPT URL the caller can revisit;
* without it a bridged answer is unattributable. None of it describes the host.
*/
function sanitizeResult(
result: BrowserRunResult,
warnings: BrowserRunWarning[] = [],
Expand All @@ -734,6 +745,12 @@ function sanitizeResult(
tookMs: result.tookMs,
answerTokens: result.answerTokens,
answerChars: result.answerChars,
modelSelection: result.modelSelection,
thinkingSelection: result.thinkingSelection,
archive: result.archive,
tabUrl: result.tabUrl,
conversationId: result.conversationId,
promptSubmitted: result.promptSubmitted,
warnings: warnings.length > 0 ? warnings : undefined,
chromePid: undefined,
chromePort: undefined,
Expand Down
31 changes: 31 additions & 0 deletions src/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,36 @@ export interface BrowserModelSelectionEvidence {
capturedAt: string;
}

export type BrowserThinkingSelectionStatus = "already-selected" | "switched" | "unverified";

/**
* Machine-checkable evidence that a requested thinking-effort tier was actually
* confirmed in ChatGPT's composer before the prompt was submitted.
*
* This exists because {@link BrowserModelSelectionEvidence} cannot carry it: for
* a Pro-capable model the picker deliberately reports the requested model string
* as the resolved label, so `resolvedLabel === requestedModel; verified: true` is
* byte-identical whether or not the Pro effort row was selected. Without a
* separate record, "this run answered at Pro effort" is unprovable after the fact.
*
* `verified` is true only for statuses that positively observed the option's
* selected state (aria-checked/aria-selected/data-state, or a composer pill whose
* label matches the target tier). `strictFailClosed` records that the run was in
* the fail-closed regime, where every non-confirming outcome throws before submit
* — so a submitted strict run is itself evidence that no degraded tier was used.
*/
export interface BrowserThinkingSelectionEvidence {
requestedLevel: ThinkingTimeLevel;
status: BrowserThinkingSelectionStatus;
resolvedLabel?: string | null;
verified: boolean;
strictFailClosed: boolean;
targetModelKind?: string | null;
observedModelKind?: string | null;
source: "chatgpt-thinking-picker";
capturedAt: string;
}

export interface BrowserRunWarning {
code: string;
severity: "warning";
Expand All @@ -150,6 +180,7 @@ export interface BrowserMetadata {
harvest?: BrowserHarvestMetadata;
archive?: BrowserArchiveResult;
modelSelection?: BrowserModelSelectionEvidence;
thinkingSelection?: BrowserThinkingSelectionEvidence;
warnings?: BrowserRunWarning[];
}

Expand Down
Loading