Skip to content
Merged
178 changes: 178 additions & 0 deletions scripts/import-cycles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";

/**
* Import-cycle ratchet for the proxy runtime (issue #6386, INV-LAYER-01).
*
* The runtime import graph once held one strongly connected component of 222 modules, so an edit
* in nearly any domain could reach nearly any other, and the three files INV-LAB-01 protects sat
* inside it. Breaking that component is incremental work. This scanner keeps the progress: the
* baseline lists every module that is still on a cycle, and the test beside it fails when a module
* outside that list joins one, or when a listed module leaves without the list shrinking with it.
*
* Edges are load-time value imports parsed by Bun: static imports, side-effect imports,
* and runtime re-exports. Resolution follows Bun's local module precedence.
* \`import type\` is erased and a dynamic \`import()\` is a deferred edge that only loads when its
* branch runs, which is the sanctioned way to break a cycle, so neither forms an edge here.
*
* Importing this module scans nothing. \`bun scripts/import-cycles.ts\` prints the current count and
* \`--update\` rewrites the baseline, which only ever removes members.
*/

export const BASELINE_REL = "tests/fixtures/import-cycle-baseline.json";
export const SOURCE_ROOT = "src";

const importScanner = new Bun.Transpiler({ loader: "ts" });
const tsxImportScanner = new Bun.Transpiler({ loader: "tsx" });

export type ImportGraph = Map<string, string[]>;

export interface CycleBaseline {
/** Modules (repo-relative, forward slashes) that sit on a static value-import cycle. */
cyclic: string[];
}

export interface CycleVerdict {
/** Modules on a cycle that the baseline does not list: a regression. */
added: string[];
/** Listed modules that are no longer on a cycle: the baseline must shrink. */
resolved: string[];
}

function toRepoPath(root: string, abs: string): string {
return relative(root, abs).split(sep).join("/");
}

function listSources(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const abs = join(dir, entry.name);
if (entry.isDirectory()) out.push(...listSources(abs));
else if (/\.(ts|tsx|mts)$/.test(entry.name) && !/\.d\.(ts|mts)$/.test(entry.name)) out.push(abs);
}
return out;
}

function resolveSpecifier(spec: string, fromFile: string): string | null {
if (!spec.startsWith(".")) return null;
try {
const candidate = Bun.resolveSync(spec, dirname(fromFile));
return /\.(ts|tsx|mts)$/.test(candidate) ? candidate : null;
} catch {
// Unresolved edges and non-TypeScript modules are outside this source graph.
return null;
}
}

/** Static value-import edges between source files in \`importer -> imported\` form. */
export function staticImports(source: string, tsx = false): string[] {
// scanImports does not accept executable entrypoint hashbangs; they are not module syntax.
const moduleSource = source.replace(/^#![^\r\n]*/, "");
return (tsx ? tsxImportScanner : importScanner).scanImports(moduleSource)
.filter(edge => edge.kind === "import-statement")
.map(edge => edge.path);
}

export function scanGraph(root: string, sourceRoot = SOURCE_ROOT): ImportGraph {
const graph: ImportGraph = new Map();
for (const abs of listSources(join(root, sourceRoot)).sort()) {
const targets = new Set<string>();
for (const spec of staticImports(readFileSync(abs, "utf8"), abs.endsWith(".tsx"))) {
const hit = resolveSpecifier(spec, abs);
if (hit) targets.add(toRepoPath(root, hit));
}
graph.set(toRepoPath(root, abs), [...targets].sort());
}
return graph;
}

/** Strongly connected components with more than one member, or a module importing itself. */
export function cyclicComponents(graph: ImportGraph): string[][] {
const index = new Map<string, number>();
const low = new Map<string, number>();
const onStack = new Set<string>();
const stack: string[] = [];
const components: string[][] = [];
let next = 0;
for (const start of graph.keys()) {
if (index.has(start)) continue;
// Iterative Tarjan: a recursive walk over a deep chain would exhaust the call stack.
const frames: { node: string; edge: number }[] = [{ node: start, edge: 0 }];
index.set(start, next);
low.set(start, next++);
stack.push(start);
onStack.add(start);
while (frames.length > 0) {
const frame = frames[frames.length - 1]!;
const edges = graph.get(frame.node) ?? [];
if (frame.edge < edges.length) {
const target = edges[frame.edge++]!;
if (!graph.has(target)) continue;
if (!index.has(target)) {
index.set(target, next);
low.set(target, next++);
stack.push(target);
onStack.add(target);
frames.push({ node: target, edge: 0 });
} else if (onStack.has(target)) {
low.set(frame.node, Math.min(low.get(frame.node)!, index.get(target)!));
}
continue;
}
frames.pop();
const parent = frames[frames.length - 1];
if (parent) low.set(parent.node, Math.min(low.get(parent.node)!, low.get(frame.node)!));
if (low.get(frame.node) !== index.get(frame.node)) continue;
const component: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
component.push(member);
} while (member !== frame.node);
const selfLoop = component.length === 1 && (graph.get(member) ?? []).includes(member);
if (component.length > 1 || selfLoop) components.push(component.sort());
}
}
return components.sort((a, b) => b.length - a.length || a[0]!.localeCompare(b[0]!));
}

export function cyclicModules(graph: ImportGraph): string[] {
return cyclicComponents(graph).flat().sort();
}

export function evaluate(current: readonly string[], baseline: CycleBaseline): CycleVerdict {
const listed = new Set(baseline.cyclic);
const now = new Set(current);
return {
added: current.filter((path) => !listed.has(path)).sort(),
resolved: baseline.cyclic.filter((path) => !now.has(path)).sort(),
};
}

/** Never adds a member: a new cycle is fixed in the source, not admitted into the list. */
export function updateBaseline(current: readonly string[], baseline: CycleBaseline): CycleBaseline {
const now = new Set(current);
return { cyclic: baseline.cyclic.filter((path) => now.has(path)).sort() };
}

export function loadBaseline(root: string): CycleBaseline {
return JSON.parse(readFileSync(join(root, BASELINE_REL), "utf8")) as CycleBaseline;
}

if (import.meta.main) {
const root = resolve(import.meta.dir, "..");
const components = cyclicComponents(scanGraph(root));
const current = components.flat().sort();
const sizes = components.map((component) => component.length).join(", ");
console.log(current.length + " modules on static import cycles in " + components.length + " components (" + sizes + ")");
if (process.argv.includes("--seed")) {
writeFileSync(join(root, BASELINE_REL), JSON.stringify({ cyclic: current }, null, 2) + "\n");
} else if (process.argv.includes("--update")) {
const next = updateBaseline(current, loadBaseline(root));
writeFileSync(join(root, BASELINE_REL), JSON.stringify(next, null, 2) + "\n");
console.log("baseline now lists " + next.cyclic.length + " modules");
} else if (process.argv.includes("--components")) {
for (const component of components) console.log(component.length + ": " + component.join(" "));
}
}
3 changes: 2 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,7 @@
"featherless-provider.test.ts": "providers",
"fetch-header-timeout.test.ts": "server",
"file-size-ratchet.test.ts": "ci-workflows",
"import-cycle-ratchet.test.ts": "ci-workflows",
"fixture-dir-uniqueness.test.ts": "ci-workflows",
"flash-route-image-modalities.test.ts": "providers",
"format-result.test.ts": "web-search",
Expand Down Expand Up @@ -1132,7 +1133,7 @@
"lab-public-wire-contract.test.ts": "lab",
"lab-read-filter-validation.test.ts": "lab",
"lab-read-surfaces.test.ts": "lab",
"launchd-repair.test.ts": "service",
"launchd-port-fallback.test.ts": "service", "launchd-repair.test.ts": "service",
"legacy-shell-compat.test.ts": "responses",
"live-call-bindings.test.ts": "server", "live-native-calls.test.ts": "server",
"live-service-manager-guard.test.ts": "service",
Expand Down
2 changes: 1 addition & 1 deletion src/claude/desktop-first-party.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { OcxConfig } from "../types";
import { claudeConfigDir } from "./auth-detect";
import { inspectDesktop3pConfigLibrary } from "./desktop-3p";
import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca";
import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime";
import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/port-policy";
import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./intercept/proxy-auth";
import {
applyClaudeInterceptSettings,
Expand Down
30 changes: 30 additions & 0 deletions src/claude/intercept/port-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { OcxConfig } from "../../types";

/**
* Port and enablement policy for the Claude intercept pair.
*
* These are pure functions of config, with no listener, CA, or picker dependency. They live
* apart from `./runtime` because the first-party Desktop path reads them too: importing them
* from `./runtime` made `desktop-first-party` reach the picker runtime, which reaches
* `desktop-first-party` back. Keeping the policy in a leaf keeps that pair acyclic.
*/

export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;

export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
if (config.runtimeRole === "client") return false;
if (config.claudeCode?.enabled === false) return false;
return config.claudeCode?.intercept?.enabled !== false;
}

export function claudeInterceptProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
const configured = config.claudeCode?.intercept?.port;
if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured;
return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET;
}

/** Desktop's egress proxy for picker mode: the port after the intercept proxy (before it at 65535). */
export function claudePickerProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
const interceptPort = claudeInterceptProxyPort(config, publicPort);
return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1;
}
21 changes: 2 additions & 19 deletions src/claude/intercept/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { discardPickerCaKey } from "./picker-ca";
import { preparePersistentPickerAuthority } from "./picker-ca-startup";
import type { PickerCaStore } from "./picker-ca-store";
import type { PickerRouteInput } from "./picker-models";
import { CLAUDE_INTERCEPT_PORT_OFFSET, claudeInterceptEnabled, claudeInterceptProxyPort, claudePickerProxyPort } from "./port-policy";
import { createPickerRuntime, pickerDesired, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
import type { SecurityRunner } from "./picker-trust";
import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./proxy-auth";
Expand Down Expand Up @@ -42,25 +43,7 @@ const PICKER_AUTHORITY_ERROR_CODES = new Set([
"picker_ca_unsupported_platform",
]);

export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;

export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
if (config.runtimeRole === "client") return false;
if (config.claudeCode?.enabled === false) return false;
return config.claudeCode?.intercept?.enabled !== false;
}

export function claudeInterceptProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
const configured = config.claudeCode?.intercept?.port;
if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured;
return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET;
}

/** Desktop's egress proxy for picker mode: the port after the intercept proxy (before it at 65535). */
export function claudePickerProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
const interceptPort = claudeInterceptProxyPort(config, publicPort);
return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1;
}
export { CLAUDE_INTERCEPT_PORT_OFFSET, claudeInterceptEnabled, claudeInterceptProxyPort, claudePickerProxyPort } from "./port-policy";

export type ClaudeInterceptOutcome = { ok: true; state: ClaudeInterceptState } | { ok: false; reason: "disabled" | "client_role" | "ephemeral_port" | "port_in_use" | "stopped" | "failed"; port?: number; message?: string };

Expand Down
4 changes: 1 addition & 3 deletions src/client/machine-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,7 @@ export function startMachineListener(
const machineApiDeps: MachineApiDeps = {
sync: deps.machineApi?.sync ?? syncConnectedClient,
disconnect: deps.machineApi?.disconnect ?? disconnectClient,
scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (tokenFingerprint => {
void import("./runtime").then(module => module.scheduleStandaloneRecycle(tokenFingerprint));
}),
scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (() => {}),
hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
...(linkMode ? { linkHomeName: () => linkHomeName(connection, sidecarOrNull) } : {}),
Expand Down
1 change: 1 addition & 0 deletions src/client/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export async function bindClientListener(
try {
const server = startListener(port, {
state: request.state,
machineApi: { scheduleStandaloneRecycle },
...(request.linkStatus ? { linkStatus: request.linkStatus } : {}),
...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}),
...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}),
Expand Down
2 changes: 1 addition & 1 deletion src/codex/account-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
import type { CodexAccount, OcxConfig } from "../types";
import type { CodexAuthContext } from "./auth-context";
import type { ProviderApiKeySelection } from "../types/provider";
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";

export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;

Expand Down
10 changes: 8 additions & 2 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { clearAccountNeedsReauth } from "./account-runtime-state";
import { readCodexTokensResult } from "./auth-collision";
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
import { clearAccountQuota } from "./quota";
import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
import { forgetManualPreference } from "./routing/active-account";
import { deleteAllHealthForAccount } from "./routing/health-store";
import { clearThreadAccountMapForAccount } from "./routing/thread-affinity";

import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
Expand Down Expand Up @@ -47,7 +49,11 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
clearAccountNeedsReauth(accountId);
clearAccountQuota(accountId);
clearThreadAccountMapForAccount(accountId);
clearCodexUpstreamHealthForAccount(accountId);
// Deliberately not via `./routing`: the account-delete path only needs the health rows and
// the manual preference gone, and importing the router here closed a cycle through
// routing/health-store and routing/active-account.
deleteAllHealthForAccount(accountId);
forgetManualPreference(accountId);
Comment thread
lcxhh521 marked this conversation as resolved.
clearCodexPoolRefreshFailure(accountId);
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
clearMainAccountInfoCache();
Expand Down
2 changes: 1 addition & 1 deletion src/codex/app-server-processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
resolveTrustedWindowsPowerShellExe,
resolveTrustedWindowsTaskkillExe,
} from "../lib/windows-elevation";
import { readCodexCatalogPath } from "./catalog/parsing";
import { readCodexCatalogPath } from "./paths";

export const STALE_CODEX_APP_SERVER_HINT =
"If Codex still shows an older model list, run `ocx sync --restart-codex`: it restarts the long-lived app-server "
Expand Down
Loading
Loading