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
6 changes: 5 additions & 1 deletion packages/review-tutor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ The model dialog lists the session's scoped models when `--models` or the settin

## Harness connectors

A harness connector owns model discovery, isolated invocation, and stream parsing while the shared runner owns process lifetime, bounds, and cancellation. Pi is registered by default; the Claude Code connector is available behind the connector flag, and Codex support is tracked in [#63](https://github.com/pickforge/pickforge-platform/issues/63). The `reviewTutorHarnessConnectors` flag defaults off. For local testing on main, set `REVIEW_TUTOR_FLAGS=reviewTutorHarnessConnectors` before starting Pi. Child processes receive only the shared environment allowlist plus keys explicitly declared by their connector, and runner failures redact common API keys, bearer credentials, and tokens before leaving the process boundary. The Claude Code connector forwards `CLAUDE_CONFIG_DIR` when present, but never forwards `ANTHROPIC_API_KEY`; users who rely on that environment key must sign in through Claude Code instead.
A harness connector owns model discovery, isolated invocation, and stream parsing while the shared runner owns process lifetime, bounds, and cancellation. Pi is registered by default; the Claude Code and Codex connectors are available behind `reviewTutorHarnessConnectors`. The flag defaults off. For local testing on main, set `REVIEW_TUTOR_FLAGS=reviewTutorHarnessConnectors` before starting Pi. Child processes receive only the shared environment allowlist plus keys explicitly declared by their connector, and runner failures redact common API keys, bearer credentials, and tokens before leaving the process boundary.

The Claude Code connector forwards `CLAUDE_CONFIG_DIR` when present, but never forwards `ANTHROPIC_API_KEY`; users who rely on that environment key must sign in through Claude Code instead.

The Codex connector discovers models through `codex app-server`, invokes reviews with `codex exec --json`, and relies on Codex's existing local authentication.

## Local data

Expand Down
245 changes: 245 additions & 0 deletions packages/review-tutor/src/connectors/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { execFile as nodeExecFile } from "node:child_process";
import type {
ConnectorRequest,
Discovery,
DiscoveryDeps,
DiscoveryExecFile,
HarnessConnector,
ParseSink,
ParsedAnswer,
SpawnSpec,
} from "./types.ts";
import { redact } from "./redact.ts";
import { ConnectorError } from "./types.ts";

const MAX_CATALOG_BYTES = 1024 * 1024;
const DISCOVERY_TIMEOUT_MS = 10_000;
const FALLBACK_LEVELS = ["low", "medium", "high"];
const MINIMUM_VERSION = [0, 140, 0] as const;
const DISCOVERY_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "CODEX_HOME"] as const;

interface CodexModel {
slug?: unknown;
display_name?: unknown;
visibility?: unknown;
supported_reasoning_levels?: unknown;
priority?: unknown;
}

interface CodexEvent {
type?: unknown;
message?: unknown;
error?: { message?: unknown };
item?: { type?: unknown; text?: unknown };
usage?: unknown;
}

export function discoveryEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return Object.fromEntries(DISCOVERY_ENV_KEYS.flatMap((key) => {
const value = source[key];
return value === undefined ? [] : [[key, value]];
}));
}

const defaultExecFile: DiscoveryExecFile = (file, args, options) => new Promise((resolve, reject) => {
nodeExecFile(file, args, { ...options, env: discoveryEnvironment(), shell: false }, (error, stdout, stderr) => {
if (error) reject(error);
else resolve({ stdout, stderr });
});
});

function versionAtLeast(version: readonly number[]): boolean {
for (let index = 0; index < MINIMUM_VERSION.length; index += 1) {
const difference = version[index]! - MINIMUM_VERSION[index]!;
if (difference !== 0) return difference > 0;
}
return true;
}

function validListedModel(value: unknown): value is CodexModel & { slug: string; visibility: "list" } {
if (!value || typeof value !== "object") return false;
const model = value as CodexModel;
return typeof model.slug === "string" && model.visibility === "list";
}

function parseModels(value: string): CodexModel[] {
const parsed = JSON.parse(value) as unknown;
const models = Array.isArray(parsed)
? parsed
: parsed && typeof parsed === "object" && Array.isArray((parsed as { models?: unknown }).models)
? (parsed as { models: unknown[] }).models
: undefined;
if (!models) throw new Error("invalid catalog");
const listed = models.filter(validListedModel);
if (listed.length === 0) throw new Error("invalid catalog");
return listed;
}

function priority(model: CodexModel): number {
return typeof model.priority === "number" && Number.isFinite(model.priority)
? model.priority
: Number.POSITIVE_INFINITY;
}

function modelChoices(models: CodexModel[]) {
return models
.sort((left, right) => priority(left) - priority(right)
|| (left.slug as string).localeCompare(right.slug as string))
.flatMap((model) => {
const slug = model.slug as string;
const levels = model.supported_reasoning_levels === undefined
? FALLBACK_LEVELS
: Array.isArray(model.supported_reasoning_levels)
? model.supported_reasoning_levels
.flatMap((level) => level && typeof level === "object"
&& typeof (level as { effort?: unknown }).effort === "string"
? [(level as { effort: string }).effort]
: [])
.filter((effort) => effort !== "max"
&& effort !== "ultra"
&& /^[a-z][a-z0-9_-]*$/.test(effort))
: [];
return levels.length === 0 ? [] : [{
id: `codex:${slug}`,
label: typeof model.display_name === "string" && model.display_name || slug,
thinkingLevels: [...levels],
}];
});
}

function parseEvent(line: string): CodexEvent | undefined {
try {
return JSON.parse(line) as CodexEvent;
} catch {
return undefined;
}
}

function numericUsage(value: unknown): Record<string, number> | undefined {
if (!value || typeof value !== "object") return undefined;
return Object.fromEntries(Object.entries(value)
.filter((entry): entry is [string, number] => typeof entry[1] === "number"));
}

function providerMessage(message: string, model: string): ConnectorError {
let safe: string;
if (/401|unauthorized|missing bearer|not logged in|login/i.test(message)) {
safe = "Codex is not logged in. Run `codex login`, then ask again.";
} else if (/429|rate limit|quota|insufficient_quota/i.test(message)) {
safe = "Codex is rate-limited or out of quota right now. Try again later.";
} else if (/model .* not (found|supported)|unknown model|invalid model/i.test(message)) {
safe = `Codex rejected model ${model}.`;
} else {
safe = redact(message).replace(/\s+/g, " ").trim().slice(0, 200);
}
return new ConnectorError(safe || "Codex failed to complete the turn.");
}

export class CodexConnector implements HarnessConnector {
readonly id = "codex" as const;
readonly label = "Codex";
readonly envKeys = ["CODEX_HOME"] as const;
private completed = false;
private lastError?: string;
private model = "unknown";

async discover(deps: DiscoveryDeps): Promise<Discovery> {
const execFile = deps.execFile ?? defaultExecFile;
let versionOutput: string;
try {
const options = {
encoding: "utf8" as const,
maxBuffer: MAX_CATALOG_BYTES,
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
timeout: DISCOVERY_TIMEOUT_MS,
};
({ stdout: versionOutput } = await execFile("codex", ["--version"], options));
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT"
? { available: false, reason: "Codex is not installed (codex not found on PATH)." }
: { available: false, reason: "Codex could not report a supported version." };
}
const match = /codex-cli (\d+)\.(\d+)\.(\d+)/.exec(versionOutput);
if (!match) {
return { available: false, reason: "Codex could not report a supported version." };
}
const version = match.slice(1, 4).map(Number);
const versionLabel = version.join(".");
if (!versionAtLeast(version)) {
return {
available: false,
reason: `Codex ${versionLabel} is too old; version 0.140.0 or newer is required.`,
};
}
try {
const options = {
encoding: "utf8" as const,
maxBuffer: MAX_CATALOG_BYTES,
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
timeout: DISCOVERY_TIMEOUT_MS,
};
const { stdout } = await execFile("codex", ["debug", "models"], options);
return { available: true, version: versionLabel, models: modelChoices(parseModels(stdout)) };
} catch {
return { available: false, reason: "Codex could not list its models." };
}
}

spawnSpec(request: ConnectorRequest): SpawnSpec {
this.model = request.model;
return {
command: "codex",
args: [
"exec", "--json", "--ephemeral", "--skip-git-repo-check",
"-s", "read-only", "-C", request.cwd,
"-m", request.model, "-c", `model_reasoning_effort=\"${request.thinking}\"`,
"-c", "shell_environment_policy.inherit=\"none\"",
"-c", "model_verbosity=\"low\"", "-",
],
};
}

parseLine(line: string, sink: ParseSink): void {
const event = parseEvent(line);
if (!event) return;
if (event.type === "item.completed"
&& event.item?.type === "agent_message"
&& typeof event.item.text === "string") {
sink.delta(event.item.text);
sink.final(event.item.text);
return;
}
if (event.type === "error" && typeof event.message === "string") {
this.lastError = event.message;
return;
}
if (event.type === "turn.failed") {
const message = typeof event.error?.message === "string"
? event.error.message
: this.lastError ?? "Codex failed to complete the turn.";
throw providerMessage(message, this.model);
}
if (event.type === "turn.completed") {
this.completed = true;
const usage = numericUsage(event.usage);
if (usage) sink.usage(usage);
}
}

finish(sink: ParseSink): ParsedAnswer {
try {
if (!this.completed) {
if (this.lastError) throw providerMessage(this.lastError, this.model);
throw new ConnectorError("Codex exited without completing the turn.");
}
if (!sink.answer?.trim()) {
throw new ConnectorError("Codex returned an empty answer.");
}
return { answer: sink.answer, ...(sink.answerUsage ? { usage: sink.answerUsage } : {}) };
} finally {
this.completed = false;
this.lastError = undefined;
this.model = "unknown";
}
}
}
4 changes: 4 additions & 0 deletions packages/review-tutor/src/connectors/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const PATTERNS = [
/token=\S+/gi,
/ghp_\w+/g,
/gho_\w+/g,
/url:\s*\S+/gi,
/cf-ray:\s*\S+/gi,
/request id:\s*\S+/gi,
/thread[_ ]id:?\s*\S+/gi,
] as const;

export function redact(value: string): string {
Expand Down
6 changes: 5 additions & 1 deletion packages/review-tutor/src/connectors/registry.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { execFile } from "node:child_process";
import type { ReviewTutorFlags } from "../flags.ts";
import { ClaudeCodeConnector } from "./claude-code.ts";
import { CodexConnector } from "./codex.ts";
import { PiConnector } from "./pi.ts";
import type {
Discovery,
DiscoveryDeps,
DiscoveryExecFile,
HarnessConnector,
HarnessId,
ModelChoice,
Expand All @@ -30,13 +32,15 @@ export function createConnectorRegistry(options: {
piModels: ModelChoice[];
piVersion?: string;
which?: DiscoveryDeps["which"];
execFile?: DiscoveryExecFile;
}): ConnectorRegistry {
const pi = new PiConnector();
const optionalConnectors: HarnessConnector[] = [new ClaudeCodeConnector()];
const optionalConnectors: HarnessConnector[] = [new ClaudeCodeConnector(), new CodexConnector()];
const dependencies: DiscoveryDeps = {
piModels: options.piModels,
...(options.piVersion ? { piVersion: options.piVersion } : {}),
which: options.which ?? which,
...(options.execFile ? { execFile: options.execFile } : {}),
};

const connectors = (): HarnessConnector[] => [
Expand Down
11 changes: 11 additions & 0 deletions packages/review-tutor/src/connectors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,21 @@ export interface HarnessConnector {
readonly envKeys?: readonly string[];
}

export type DiscoveryExecFile = (
file: string,
args: string[],
options: {
encoding: "utf8";
maxBuffer: number;
timeout: number;
},
) => Promise<{ stdout: string; stderr: string }>;

export interface DiscoveryDeps {
piModels: ModelChoice[];
piVersion?: string;
which(command: string): Promise<string | undefined>;
execFile?: DiscoveryExecFile;
}

export type Discovery =
Expand Down
Loading