diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index e582b03428..6f672b8ed5 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -81,6 +81,11 @@ data-key rotation, revocation, and disconnect. ### What a connected client shows +`ocx connect` and `ocx connect status` check catalog readiness against the first valid local +Codex runtime in selection order. Failed preferred candidates can fall back, but lower-priority +alternatives are not probed after a valid runtime is selected. This check leaves the saved runtime +selection unchanged. General `ocx status` still discovers alternatives for runtime diagnostics. + A client stores no provider credentials and no catalog of its own, so its local config and credential store are empty by design — and reading them as the truth produces a confident, wrong answer about what the hub can serve. On a connected client `ocx status` therefore leads with diff --git a/src/cli/connect.ts b/src/cli/connect.ts index c656d5b2e6..dba3ad4aa6 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -42,6 +42,15 @@ export interface ClientCommandDeps extends RuntimeApiDeps { export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps { /** Injected in tests; defaults to reading the materialized client catalog off disk. */ readCatalogBody?: () => string | null; + /** + * A Codex command the caller already resolved, handed over so readiness skips resolving it + * again. General `ocx status` resolves the full runtime for its diagnostics block; the resolver + * memo is keyed by discovery scope and holds one entry, so a priority-only readiness resolve + * and the full one miss each other and re-probe the same command with `--version` — up to eight + * seconds apiece. Passing the command across adds no cache state, and it cannot disagree with + * what status prints because it is the selection status is printing. + */ + selectedCodexCommand?: string; } export const CONNECT_USAGE = `Usage: @@ -103,11 +112,15 @@ function readInstalledCatalogBody(): string | null { * `codexSupportedReasoningEfforts()` with no deps reaches `resolveAndPersistCodexRuntime`, which * writes codex-runtime.json. `ocx status` deliberately resolves without persisting, and a * read-only diagnostics command should not start writing runtime selection state because a - * readiness check was added to it. Handing the already-resolved command in as the only candidate - * skips that path and reuses the resolve cache `ocx status` has usually already filled. + * readiness check was added to it. Stop at the first valid runtime, then hand only that command + * to the catalog probe: readiness does not consume alternative-runtime diagnostics. The resolver + * keeps this priority-only cache separate from the full discovery used by `ocx status`. + * + * A caller that has already resolved passes its selection in through `selectedCodexCommand` rather + * than paying for a second `--version` probe of the command it just resolved. */ -function observeLocalCodexEffortLadder(): ReadonlySet | null { - const command = resolveCodexRuntime().runtime.command; +function observeLocalCodexEffortLadder(selected?: string): ReadonlySet | null { + const command = selected ?? resolveCodexRuntime({ discoverAlternatives: false }).runtime.command; return codexSupportedReasoningEfforts({ commandCandidates: () => [command] }); } @@ -119,7 +132,8 @@ function observeLocalCodexEffortLadder(): ReadonlySet | null { * a single `ocx connect`. */ function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps { - return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder }; + const selected = deps?.selectedCodexCommand; + return { supportedEfforts: deps?.supportedEfforts ?? (() => observeLocalCodexEffortLadder(selected)) }; } /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */ diff --git a/src/cli/status.ts b/src/cli/status.ts index e120b5de70..b77f494082 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -509,7 +509,27 @@ export async function collectStatus(): Promise { desiredEnabled: claudeDesktopIntegrationEnabled(config), policy: claudeDesktopPolicyHealth(probeClaudeDesktopPolicy()), }; - const clientConnection = collectClientConnectionStatus(); + const resolvedRuntime = (() => { + try { + return resolveCodexRuntime(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const redacted = redactUserPath(redactSecretString(message)).slice(0, 160); + return { + runtime: { command: "codex", version: null, source: "fallback" as const }, + failures: [{ + command: "codex", + source: "fallback" as const, + reason: `resolve threw: ${redacted}`, + }], + replacedConfigured: undefined, + newerAvailable: undefined, + }; + } + })(); + const clientConnection = collectClientConnectionStatus(Date.now(), undefined, { + selectedCodexCommand: resolvedRuntime.runtime.command, + }); // Asked before the local probes below so a connected client's report is hub-sourced from its // first line. Bounded and failure-tolerant: an offline hub degrades the remoteHub block, it // does not fail `ocx status`. @@ -567,24 +587,6 @@ export async function collectStatus(): Promise { routingKind: getCodexRoutingKind(), }); const codexPlugins = diagnoseCodexBundledPlugins(); - const resolvedRuntime = (() => { - try { - return resolveCodexRuntime(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const redacted = redactUserPath(redactSecretString(message)).slice(0, 160); - return { - runtime: { command: "codex", version: null, source: "fallback" as const }, - failures: [{ - command: "codex", - source: "fallback" as const, - reason: `resolve threw: ${redacted}`, - }], - replacedConfigured: undefined, - newerAvailable: undefined, - }; - } - })(); const lastClamp = loadLastEffortClamp(); const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime); const codexHome = collectOrcaCodexHomeDiagnostic(); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 00a0b78dbe..a8940054fc 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -12,6 +12,9 @@ Claude-only connections keep their existing non-failing readiness policy; displa ## Connected Claude Desktop profiles +The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. +It does not discover lower-priority alternatives after a valid selection or alter Desktop ownership. + Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin and exact hub-issued IDs to the local Desktop configuration. Static/hybrid embed the entries; discovery-only keeps discovery on the hub. The hub owns family assignments and defaults; local diff --git a/structure/config.md b/structure/config.md index ac643d3c7f..d70ba43dcf 100644 --- a/structure/config.md +++ b/structure/config.md @@ -249,6 +249,9 @@ the residual directory for manual review; there is no recursive-delete fallback. The connection's `tokenFingerprint` participates in [`ocx status` credential binding](runtime.md#remote-hub-status-credential-binding). +Client catalog readiness observes the selected Codex runtime without creating or rewriting +`codex-runtime.json`; general status reuses its already-resolved command under the [runtime contract](runtime.md#remote-hub-hardening-ownership). + Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. Codex display-cache expiry, retained main-policy evidence, and reset history follow the diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 2789f3bd11..631c012e06 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -19,6 +19,9 @@ Native retirement keeps active model/quota instructions aligned across locales w [catalog contract](../catalog.md#shared-catalog). Historical records and other providers sharing a model-name fragment remain distinct from current Codex-native support. +The Remote Hub guide distinguishes selected-runtime readiness from general runtime diagnostics; +`tests/cli/cli-connect-readiness.test.ts` exercises that boundary and general status's single discovery pass with isolated executable fixtures. + ## GitHub Pages `.github/workflows/deploy-docs.yml` publishes the docs to: diff --git a/structure/runtime.md b/structure/runtime.md index 3287baba40..98b795a188 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -286,6 +286,11 @@ provider name for that assessment, so `planPassthroughWebSearchBridge` takes it ## Remote Hub hardening ownership +`src/cli/connect.ts` resolves only through the first valid local Codex runtime for catalog +readiness, then reads that runtime's effort ladder without persisting its selection. Rejected +preferred candidates still fall back in priority order. General `ocx status` retains full runtime +discovery and passes its resolved command into readiness, avoiding a second version probe without adding cache state. + `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. ### Remote Hub status credential binding diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts index bb739cdbf0..11af5522e4 100644 --- a/tests/cli/cli-connect-readiness.test.ts +++ b/tests/cli/cli-connect-readiness.test.ts @@ -7,15 +7,15 @@ * The write-time gate added in the first round cannot close this. It runs once, on bytes about * to be written, so it says nothing about a catalog that predates it, one written while the * runtime ladder was unverified, or a runtime swapped after the write. These tests drive the - * status surface itself, in a real client home, with the ladder injected so no Codex process is - * spawned to observe it. + * status surface itself, in an isolated client home, with injected ladders or harmless fixture + * launchers in place of the operator's Codex runtime. */ import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -24,6 +24,7 @@ import { dispatchCommand } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { ClientCatalogIncompatibleError } from "../../src/client/catalog-compatibility"; import type { ClientCatalogReadiness } from "../../src/client/catalog-compatibility"; +import type { RuntimeProbeFailure } from "../../src/codex/runtime"; /** Codex CLI 0.135.0's ladder, verbatim from the parse error in the issue. */ const OLD_CLI = ["none", "minimal", "low", "medium", "high", "xhigh"]; @@ -39,24 +40,71 @@ type ProbeResult = { exitCode?: number; errors: string[]; catalogUnchanged?: boolean; + commandCode?: number; status: { state: string; catalog: string; readiness?: string; readinessReason?: string; }; + runtime?: { + beforeDiagnostics: Record; + afterDiagnostics: Record; + diagnosticsCached: boolean; + newerVersion?: string; + selectionUnchanged: boolean; + failures: RuntimeProbeFailure[]; + }; }; +/** Harmless real launchers: the fixture PATH never includes the operator's Codex. */ +function writeRuntimeFixture(dir: string, version: string, valid = true): string { + mkdirSync(dir, { recursive: true }); + const command = join(dir, process.platform === "win32" ? "codex.cmd" : "codex"); + const catalog = JSON.stringify({ models: [{ + slug: "gpt-5.6-sol", + base_instructions: "fixture", + supported_reasoning_levels: NEW_CLI.map(effort => ({ effort })), + }] }); + writeFileSync(command, process.platform === "win32" + ? [ + "@echo off", + 'echo %~1 %~2 %~3>>"%~dp0calls.log"', + ...(valid ? [ + 'if "%~1"=="--version" (', + ` echo codex-cli ${version}`, + " exit /b 0", + ")", + `echo ${catalog}`, + "exit /b 0", + ] : ["exit /b 1"]), + ].join("\r\n") + : [ + "#!/bin/sh", + 'printf "%s\\n" "$*" >> "${0%/*}/calls.log"', + ...(valid ? [ + `if [ "$1" = "--version" ]; then printf '%s\\n' 'codex-cli ${version}'; exit 0; fi`, + `printf '%s\\n' '${catalog}'`, + ] : ["exit 1"]), + ].join("\n"), "utf8"); + if (process.platform !== "win32") chmodSync(command, 0o755); + return command; +} + /** * Runs the real "ocx connect status" surface against a throwaway client home. The ladder is - * injected rather than observed: a spawned "codex debug models" would make the assertion depend - * on whichever Codex CLI the test machine happens to have. + * injected by default; the observer cases use only the isolated fixture launchers below. */ function runStatusProbe(options: { connected: boolean; - ladder: string[] | null | "forbidden"; + ladder: string[] | null | "forbidden" | "observed"; catalog?: string; connectRejectCatalog?: string; + preferred?: "valid" | "failed" | "missing"; + persisted?: boolean; + fullDiagnostics?: boolean; + /** "connect" drives `ocx connect status`; "status" drives the general `ocx status` collector. */ + surface?: "connect" | "status"; }): ProbeResult { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-codex-")); @@ -89,20 +137,62 @@ function runStatusProbe(options: { writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); } writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); + const runtimeEnv: NodeJS.ProcessEnv = {}; + if (options.ladder === "observed") { + const selectedDir = join(opencodexHome, "selected"); + const lowerDir = join(opencodexHome, "lower"); + const rejectedDir = join(opencodexHome, "rejected"); + const selected = writeRuntimeFixture(selectedDir, "0.145.0"); + writeRuntimeFixture(lowerDir, "99.0.0"); + const preferred = options.preferred ?? "valid"; + runtimeEnv.CODEX_CLI_PATH = preferred === "valid" ? selected + : preferred === "failed" ? writeRuntimeFixture(rejectedDir, "", false) + : join(rejectedDir, process.platform === "win32" ? "codex.cmd" : "codex"); + runtimeEnv.PATH = [selectedDir, lowerDir].join(delimiter); + runtimeEnv.HOME = opencodexHome; + runtimeEnv.USERPROFILE = opencodexHome; + runtimeEnv.FIXTURE_RUNTIME_DIRS = JSON.stringify({ selected: selectedDir, lower: lowerDir, rejected: rejectedDir }); + runtimeEnv.FIXTURE_FULL_DIAGNOSTICS = options.fullDiagnostics ? "1" : "0"; + if (options.persisted) writeFileSync(join(opencodexHome, "codex-runtime.json"), JSON.stringify({ + version: 1, command: selected, source: "configured", selectedVersion: "0.145.0", + updatedAt: "2026-08-28T00:00:00.000Z", + })); + } const script = ` const { collectClientConnectionStatus, handleConnectCommand } = require("./src/cli/connect"); + const { readFileSync } = require("node:fs"); + const { join } = require("node:path"); const ladder = JSON.parse(process.env.FIXTURE_LADDER); const supportedEfforts = ladder === "forbidden" ? () => { throw new Error("the runtime was probed on a path that must not probe it"); } : ladder === null ? () => null : () => new Set(ladder); + const catalogProbeDeps = ladder === "observed" ? {} : { supportedEfforts }; + const readOptional = path => { try { return readFileSync(path, "utf8"); } catch { return null; } }; + const dirs = process.env.FIXTURE_RUNTIME_DIRS ? JSON.parse(process.env.FIXTURE_RUNTIME_DIRS) : null; + const calls = () => Object.fromEntries(Object.entries(dirs ?? {}).map(([key, dir]) => + [key, (readOptional(join(dir, "calls.log")) ?? "").split(/\\r?\\n/).map(line => line.trim()).filter(Boolean)])); + const selectionPath = join(process.env.OPENCODEX_HOME, "codex-runtime.json"); + const selectionBefore = readOptional(selectionPath); const lifecycleLockDeps = { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }; const captured = []; const errors = []; const real = console.log; const realError = console.error; (async () => { - let exitCode, catalogUnchanged; + let exitCode, catalogUnchanged, commandCode; + if (process.env.FIXTURE_SURFACE === "status") { + const { collectStatus } = require("./src/cli/status"); + const view = await collectStatus(); + const observed = calls(); + console.log(JSON.stringify({ + lines: [], commandCode: 0, status: view.json.connection, + runtime: { beforeDiagnostics: observed, afterDiagnostics: observed, diagnosticsCached: true, + selectionUnchanged: selectionBefore === readOptional(selectionPath), failures: [] }, + exitCode, errors, catalogUnchanged, + })); + return; + } console.log = (...parts) => captured.push(parts.join(" ")); console.error = (...parts) => errors.push(parts.join(" ")); try { @@ -134,7 +224,7 @@ function runStatusProbe(options: { }); catalogUnchanged = fs.readFileSync(catalogPath, "utf8") === before; } else { - await handleConnectCommand(["status"], { lifecycleLockDeps, catalogProbeDeps: { supportedEfforts } }); + commandCode = await handleConnectCommand(["status"], { lifecycleLockDeps, catalogProbeDeps }); } } finally { console.log = real; @@ -143,9 +233,27 @@ function runStatusProbe(options: { const status = collectClientConnectionStatus( Date.parse("2026-08-28T00:00:10.000Z"), lifecycleLockDeps, - { supportedEfforts }, + catalogProbeDeps, ); - console.log(JSON.stringify({ lines: captured, status, exitCode, errors, catalogUnchanged })); + let runtime; + if (ladder === "observed") { + const beforeDiagnostics = calls(); + const { resolveCodexRuntime } = require("./src/codex/runtime"); + // Same priority-only scope the status path resolved with, so this reads the memo that + // path published instead of probing again, and reports the candidates it rejected. + const failures = resolveCodexRuntime({ discoverAlternatives: false }).failures; + let newerVersion; + let diagnosticsCached = true; + if (process.env.FIXTURE_FULL_DIAGNOSTICS === "1") { + newerVersion = resolveCodexRuntime().newerAvailable?.version; + const first = JSON.stringify(calls()); + resolveCodexRuntime(); + diagnosticsCached = first === JSON.stringify(calls()); + } + runtime = { beforeDiagnostics, afterDiagnostics: calls(), diagnosticsCached, newerVersion, + selectionUnchanged: selectionBefore === readOptional(selectionPath), failures }; + } + console.log(JSON.stringify({ lines: captured, commandCode, status, runtime, exitCode, errors, catalogUnchanged })); })(); `; @@ -166,10 +274,18 @@ function runStatusProbe(options: { // Desktop configuration, even transitively. OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), FIXTURE_LADDER: JSON.stringify(options.ladder), + FIXTURE_SURFACE: options.surface ?? "connect", + ...runtimeEnv, }, }); expect(result.status).toBe(0); - return JSON.parse(result.stdout.trim().split("\n").at(-1)!) as ProbeResult; + const probe = JSON.parse(result.stdout.trim().split("\n").at(-1)!) as ProbeResult; + // A status command that exited nonzero printed no verdict worth asserting on, so every + // readiness expectation below would otherwise be checking a report that was never produced. + if (!options.connectRejectCatalog) { + expect(probe.commandCode).toBe(0); + } + return probe; } finally { removeTreeWithRetry(opencodexHome); removeTreeWithRetry(codexHome); @@ -277,6 +393,77 @@ describe("#4207 connected-client readiness", () => { }); }); +describe("connected-client runtime probe scope", () => { + test("observes only the selected runtime and leaves full diagnostics available", () => { + const probe = runStatusProbe({ connected: true, ladder: "observed", fullDiagnostics: true }); + + expect(probe.status.readiness).toBe("ready"); + expect(probe.runtime?.beforeDiagnostics.lower).toEqual([]); + expect(probe.runtime?.beforeDiagnostics.selected).toEqual([ + "--version", "debug models --bundled", "debug models --bundled", + ]); + // The preferred runtime answered, so the readiness scope rejected no candidate at all. + expect(probe.runtime?.failures).toEqual([]); + expect(probe.runtime?.newerVersion).toBe("99.0.0"); + expect(probe.runtime?.afterDiagnostics.lower).toEqual(["--version"]); + expect(probe.runtime?.diagnosticsCached).toBe(true); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); + + test("a rejected preferred runtime falls back without rewriting the saved selection", () => { + const probe = runStatusProbe({ connected: true, ladder: "observed", preferred: "failed", persisted: true }); + + expect(probe.status.readiness).toBe("ready"); + expect(probe.runtime?.beforeDiagnostics.lower).toEqual([]); + expect(probe.runtime?.beforeDiagnostics.rejected).toEqual(["--version"]); + expect(probe.runtime?.beforeDiagnostics.selected).toEqual([ + "--version", "debug models --bundled", "debug models --bundled", + ]); + // The fallback is only meaningful if the preferred runtime was probed and refused, so the + // resolver has to say so rather than leave a silent selection. + const rejected = probe.runtime?.failures.filter(item => item.source === "environment") ?? []; + expect(rejected).toHaveLength(1); + expect(rejected[0]?.command).toContain("rejected"); + expect(rejected[0]?.reason).toContain("failed --version"); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); + + test("a missing preferred runtime falls back to the first valid PATH candidate", () => { + const probe = runStatusProbe({ connected: true, ladder: "observed", preferred: "missing" }); + + expect(probe.status.readiness).toBe("ready"); + expect(probe.runtime?.beforeDiagnostics.lower).toEqual([]); + expect(probe.runtime?.beforeDiagnostics.selected).toEqual([ + "--version", "debug models --bundled", "debug models --bundled", + ]); + const missing = probe.runtime?.failures.filter(item => item.source === "environment") ?? []; + expect(missing).toHaveLength(1); + expect(missing[0]?.reason).toBe("path does not exist"); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); + + test("a general ocx status does not re-probe the runtime it already resolved", () => { + // General `ocx status` answers readiness and then reports full runtime diagnostics. Both + // land on the same selected command, and each `codex --version` probe is allowed up to + // eight seconds, so resolving it twice is latency the operator pays for nothing. The + // readiness scope caches under its own key, so before the fix the second resolution missed. + const probe = runStatusProbe({ connected: true, ladder: "observed", surface: "status" }); + + expect(probe.status.readiness).toBe("ready"); + // One full discovery pass, then the ladder. The pass probes the configured path and the + // bare `codex` fallback as separate candidates, which PATH resolves back to this fixture; + // what must not appear is a third `--version` after `debug models`, which is what the + // readiness scope added when it resolved the selection for itself. + expect(probe.runtime?.afterDiagnostics.selected).toEqual([ + "--version", "--version", "debug models --bundled", + ]); + // Full discovery still runs: the lower-priority candidate is still version-probed, so the + // saving comes from reusing the selection rather than from narrowing what status reports. + expect(probe.runtime?.afterDiagnostics.lower).toEqual(["--version"]); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); +}); + describe("#4207 what ocx connect reports when the local CLI cannot use the catalog", () => { const incompatible: ClientCatalogReadiness = { kind: "incompatible",