From f5968d6626e16110fa1ee08e7c2c686dfdbeae71 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:35 +0900
Subject: [PATCH 01/14] Revert "fix(google): clear replayed thought signatures
on a real HTTP 400 (#8054) (#8150)"
This reverts commit e76511d663b2d0b2447e193daf36f2aa96c9e586.
---
.../src/content/docs/reference/adapters.md | 6 +-
src/adapters/google.ts | 26 ++------
structure/providers/google.md | 13 ----
...google-signature-history-roundtrip.test.ts | 65 -------------------
4 files changed, 6 insertions(+), 104 deletions(-)
diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md
index ed227c8abf5..528b71842c8 100644
--- a/docs-site/src/content/docs/reference/adapters.md
+++ b/docs-site/src/content/docs/reference/adapters.md
@@ -459,11 +459,7 @@ Use `liveModels: false` for a static catalog; Settings can switch between the tw
- Tool-call ids are synthesized when Gemini omits them. Vertex and Antigravity preserve and replay
opaque `thoughtSignature` values so tool-result continuations retain Gemini reasoning continuity.
The signature cache is snapshotted to the config directory, so continuations also survive proxy
- restarts. On Vertex and Antigravity, when upstream rejects a request with any HTTP 400, or with
- `INVALID_ARGUMENT` or a signature error inside a 200 response, that conversation's in-memory
- signature replay entry is cleared so the next turn does not replay it. A rejection whose text
- names a signature also evicts the rejected signatures from the durable store; any other
- rejection leaves durable entries in place. The rejected request itself is not retried.
+ restarts.
- Antigravity's Gemini 4 Argon requests include the selected thinking tier when thought summaries
are enabled, allowing its thought summaries to appear in supported clients.
- **Malformed response shapes fail closed.** A claimed candidate, its `content`, or its
diff --git a/src/adapters/google.ts b/src/adapters/google.ts
index 97d6167f30a..c6ff9d971a3 100644
--- a/src/adapters/google.ts
+++ b/src/adapters/google.ts
@@ -22,7 +22,6 @@ import { contentPartsToText, parseDataUrl } from "./image";
import { bindVertexRequestAuthority, GCP_CREDENTIAL_MARKER_PREFIX, gcpCredentialMarkerAccount, getVertexAccessTokenWithAuthority } from "../lib/gcp-adc";
import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
-import { readDisplaySafeErrorPayloadText } from "./upstream-http-error";
import { isVertexTruncatedTurn, vertexTruncationErrorEvent } from "./google-truncation";
import { antigravitySessionAnchor, antigravityUserAgentForWireModel, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
import { summarizeGoogleWireShape } from "./google-wire-shape";
@@ -947,15 +946,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
// session replay cache), but durable-store eviction is intentionally narrower: it only runs
// when the error text explicitly mentions a signature, so a generic tool-schema
// INVALID_ARGUMENT does not destroy valid durable signatures.
- //
- // `httpStatus` is the real upstream status when the rejection arrived as an HTTP error. The
- // normalized Vertex/Antigravity error text keeps only Google's message, not its status enum,
- // so a 400 such as "bad model" would otherwise leave the poisoned session entry in place (#8054).
- function handleSignatureRejection(errorMessage?: string, httpStatus?: number) {
+ function handleSignatureRejection(errorMessage?: string) {
const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
const text = errorMessage ?? "";
- const isInvalidArgument = httpStatus === 400 || /invalid_argument|invalid argument/i.test(text);
+ const isInvalidArgument = /invalid_argument|invalid argument/i.test(text);
const isSignatureError = /signature|thought_signature|thoughtSignature/i.test(text);
// The in-memory Antigravity replay cache only exists for CCA/Vertex, so clearing it stays
// scoped to those modes (replayModel/replaySession are undefined elsewhere anyway).
@@ -987,23 +982,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
// 429 rotation and raw error formatting.
...(provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist"
? {
- fetchResponse: async (request: AdapterRequest, ctx?: AdapterFetchContext): Promise => {
- const res = await (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(
+ fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise =>
+ (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(
request,
ctx,
{ toolSchemaProfile, toolSchemaPolicy },
- );
- // A rejected replay usually arrives as a real HTTP 400, and the dispatcher reports a
- // non-ok response without ever calling parseStream/parseResponse — the only other
- // callers of clear-on-invalid. Without this, the bad signature stays cached and is
- // replayed on every turn until the TTL expires (#8054). Read a clone so the body the
- // dispatcher formats is untouched, and await it so the next turn cannot race ahead of
- // the eviction. No resend here: payload rejection stays resend-is-futile.
- if (res.status === 400) {
- handleSignatureRejection(await readDisplaySafeErrorPayloadText(res.clone(), ctx?.abortSignal), res.status);
- }
- return res;
- },
+ ),
formatErrorBody: (status: number, _headers: Headers, payloadText: string): string =>
(provider.googleMode === "cloud-code-assist" ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText),
}
diff --git a/structure/providers/google.md b/structure/providers/google.md
index 6b36e11c2f4..134d06b2aec 100644
--- a/structure/providers/google.md
+++ b/structure/providers/google.md
@@ -155,19 +155,6 @@ A first `functionCall` that still has no real signature after replay receives th
`skip_thought_signature_validator` sentinel through `applyAntigravityThoughtSignatureFallback`, on
Antigravity, Vertex and AI Studio alike. AI Studio gained it for combo hops that hand Gemini a call
another model issued (#7298); non-Gemini model ids never receive it.
-An upstream rejection clears the session's replay entry whether it arrives inside a 200 body or
-stream or as a real HTTP 400: the Antigravity/Vertex `fetchResponse` wrapper in
-`src/adapters/google.ts` reads a clone of the final 400 body and runs the same clear-on-invalid
-check, because the dispatcher reports a non-ok response without calling the adapter parsers. The
-400 status itself counts as an invalid-argument rejection for the session clear, since the
-normalized error text keeps only Google's message and not its status enum. The
-next turn therefore signs its first call with the sentinel instead of replaying a session-cached
-rejected value until TTL expiry (#8054). Durable-store eviction keeps its narrower gate: only a
-rejection whose text names a signature evicts the injected call ids, so a generic tool-schema
-`INVALID_ARGUMENT` cannot destroy valid durable signatures, and a durable entry behind a generic
-rejection stays until its TTL. The rejected request itself is not resent; `payload-rejected` stays
-resend-is-futile. `tests/adapters/google/google-signature-history-roundtrip.test.ts` drives the
-400 through `fetchResponse` on both modes.
`src/adapters/openai-responses/passthrough.ts` removes item-level `extra_content` through
`stripProviderInputItemMetadata` in `src/adapters/openai-responses/request-strips.ts` only for
diff --git a/tests/adapters/google/google-signature-history-roundtrip.test.ts b/tests/adapters/google/google-signature-history-roundtrip.test.ts
index ebf503badbd..8cdc3674082 100644
--- a/tests/adapters/google/google-signature-history-roundtrip.test.ts
+++ b/tests/adapters/google/google-signature-history-roundtrip.test.ts
@@ -11,7 +11,6 @@ import {
__resetAntigravityReplayCache,
applyAntigravityReplay,
observeAntigravityReplay,
- THOUGHT_SIGNATURE_BYPASS,
} from "../../../src/adapters/google-antigravity-replay";
import { parseRequest } from "../../../src/responses/parser";
import {
@@ -615,70 +614,6 @@ describe("#1735 thought signature survives history replay", () => {
// Unrelated call from another turn is preserved
expect(lookupReplayThoughtSignature("call_unrelated_3", scope)).toBe(SIG_C);
});
-
- // #8054: the 200-body tests above never reach the transport. A real upstream rejection is an
- // HTTP 400 that the dispatcher reports without calling parseResponse, so the clear has to run
- // in the adapter's own fetch path — exercised here through fetchResponse with a non-200 send.
- // The second case carries no "invalid argument" or "signature" wording: Google's normalized 400
- // text keeps only the message, so the HTTP 400 status itself has to trigger the clear.
- const rejections = [
- { label: "INVALID_ARGUMENT text", message: "Request contains an invalid argument." },
- { label: "status-only", message: "bad model" },
- ] as const;
- for (const mode of ["cloud-code-assist", "vertex"] as const) for (const rejection of rejections) {
- test(`${mode}: a real HTTP 400 (${rejection.label}) drops the cached signature so the next turn uses the sentinel`, async () => {
- const modeProvider = mode === "cloud-code-assist"
- ? { ...provider, googleMode: mode, baseUrl: "https://daily-cloudcode-pa.googleapis.com", project: "test-proj", apiKey: "test-token" } as OcxProviderConfig
- : provider;
- const user = { role: "user", content: [{ type: "input_text", text: "issue-8054 session" }] };
- const tools = [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }];
- // Unscoped, so the durable store stays empty and only the session replay cache can sign.
- const replayTurn = () => parseRequest({
- model: MODEL,
- stream: false,
- input: [
- user,
- { type: "function_call", call_id: "call_8054", name: "shell_command", arguments: "{\"cmd\":\"pwd\"}" },
- { type: "function_call_output", call_id: "call_8054", output: "/tmp" },
- ],
- tools,
- });
- const firstCallSignature = (body: string) => {
- const envelope = JSON.parse(body) as { request?: { contents: unknown[] } };
- const wire = mode === "cloud-code-assist" ? JSON.stringify(envelope.request) : body;
- return modelParts(wire).find(part => "functionCall" in part)?.thoughtSignature;
- };
-
- // Turn 1: upstream mints a signature, which the replay cache observes.
- const first = createGoogleAdapter(modeProvider);
- await first.buildRequest(parseRequest({ model: MODEL, stream: false, input: [user], tools }));
- const minted = googleBody([fcPart("shell_command", { cmd: "pwd" }, SIGNATURE)]);
- await first.parseResponse!(new Response(JSON.stringify(mode === "cloud-code-assist" ? { response: minted } : minted)));
-
- // Turn 2 replays it and upstream rejects the request with a real 400.
- const second = createGoogleAdapter(modeProvider);
- const built = await second.buildRequest(replayTurn());
- expect(firstCallSignature(built.body as string)).toBe(SIGNATURE);
- let sends = 0;
- const executor = (async () => {
- sends++;
- return new Response(JSON.stringify({
- error: { code: 400, status: "INVALID_ARGUMENT", message: rejection.message },
- }), { status: 400, headers: { "content-type": "application/json" } });
- }) as unknown as typeof fetch;
- const res = await second.fetchResponse!(built, { executor, timeoutMs: 5_000 });
- expect(res.status).toBe(400);
- // No resend: payload rejection stays resend-is-futile.
- expect(sends).toBe(1);
- // The dispatcher still owns an unread body.
- expect(await res.text()).toContain(rejection.message);
-
- // Turn 3 must not replay the rejected signature; the first call falls back to the sentinel.
- const third = createGoogleAdapter(modeProvider);
- const retried = await third.buildRequest(replayTurn());
- expect(firstCallSignature(retried.body as string)).toBe(THOUGHT_SIGNATURE_BYPASS);
- });
- }
});
describe("Antigravity Multi-Signature History Stability (Mechanism ②)", () => {
From 6c77dccd98c6835fb3cf26e976c0ea5aaf8dbc1c Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 02/14] Revert "fix(relay): report an untyped SSE error
envelope as response.failed (#8299)"
This reverts commit 9aeffab72b28e0740560d55aadec2547dcae286a.
---
src/server/relay.ts | 49 +++-----
src/server/ws-bridge.ts | 18 ---
.../transports/responses-combo-boundaries.md | 6 +-
structure/transports/responses-wire-shapes.md | 10 +-
tests/responses/sse-failed-tail.test.ts | 113 ------------------
tests/routing/combo-stream-preflight.test.ts | 12 --
6 files changed, 23 insertions(+), 185 deletions(-)
diff --git a/src/server/relay.ts b/src/server/relay.ts
index bdd5c26e39e..0e29bd85c85 100644
--- a/src/server/relay.ts
+++ b/src/server/relay.ts
@@ -227,19 +227,9 @@ export function refusalFailedTailFrame(
);
}
-/**
- * A bare upstream error event: the `type: "error"` SSE event, or an untyped envelope that
- * carries only an `error` object (`{"error":{...}}`, #8216). Some OpenAI-compatible gateways
- * end a stream with the untyped form; without this it was dropped and EOF reported
- * `adapter_eof`. A typed event of any other kind is never bare, even with an `error` field.
- */
-function isBareUpstreamErrorEvent(root: JsonRecord): boolean {
- return root.type === "error" || (root.type === undefined && asJsonRecord(root.error) !== null);
-}
-
function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined {
- const root = asJsonRecord(payload);
- if (!root || !isBareUpstreamErrorEvent(root)) return undefined;
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)
+ || (payload as { type?: unknown }).type !== "error") return undefined;
const message = upstreamErrorMessageFromPayload(payload);
return message ? redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS) : undefined;
}
@@ -266,7 +256,7 @@ function boundedBareUpstreamError(payload: unknown): {
errorCode: string | undefined;
} | undefined {
const root = asJsonRecord(payload);
- if (!root || !isBareUpstreamErrorEvent(root)) return undefined;
+ if (!root || root.type !== "error") return undefined;
const message = boundedBareUpstreamErrorMessage(payload);
const response = asJsonRecord(root.response);
// Precedence is {@link upstreamErrorMessageFromPayload}'s, so the envelope
@@ -282,7 +272,7 @@ function boundedBareUpstreamError(payload: unknown): {
];
const code = candidates.map(candidate => stringField(candidate, "code"))
.find(candidate => candidate !== undefined)?.slice(0, 128);
- // Root `type` is the SSE event discriminator (`"error"` or absent), not an error class. Only nested
+ // Root `type` is the SSE event discriminator (`"error"`), not an error class. Only nested
// error records can authoritatively name classes such as rate_limit_error or server_error.
const errorType = candidates.slice(0, -1).map(candidate => stringField(candidate, "type"))
.find(candidate => candidate !== undefined)?.slice(0, 128);
@@ -290,15 +280,13 @@ function boundedBareUpstreamError(payload: unknown): {
? (isTerminalRefusalCode(code) ? code : undefined)
: message === undefined ? undefined : safetyRefusalCodeFromMessage(message);
if (message !== undefined) return { message, refusalCode, errorType, errorCode: code };
- if (refusalCode !== undefined) {
- return { message: terminalRefusalFallbackMessage(refusalCode), refusalCode, errorType, errorCode: code };
- }
- // A structured code or class with no message is still the upstream's verdict, so it must end in
- // `response.failed` rather than `adapter_eof`. Its own bounded identifiers are the only
- // diagnostic it sent; the tail redacts and masks them like any other message.
- const identity = code ?? errorType;
- if (identity === undefined) return undefined;
- return { message: `Upstream error without a message (${identity})`, refusalCode, errorType, errorCode: code };
+ if (refusalCode === undefined) return undefined;
+ return {
+ message: terminalRefusalFallbackMessage(refusalCode),
+ refusalCode,
+ errorType,
+ errorCode: code,
+ };
}
export type SseTerminalOutputBoundary = {
@@ -682,9 +670,8 @@ function parseSsePayload(payload: string): unknown | undefined {
}
function isPolicyRewriteType(parsed: unknown): boolean {
- const root = asJsonRecord(parsed);
- if (!root) return false;
- return root.type === "response.failed" || root.type === "response.incomplete" || isBareUpstreamErrorEvent(root);
+ const type = asJsonRecord(parsed)?.type;
+ return type === "response.failed" || type === "response.incomplete" || type === "error";
}
/**
@@ -815,9 +802,7 @@ function createFirstOutputReporter(onFirstOutput?: () => void): {
}
export function terminalStatusFromParsed(parsed: unknown): ResponsesTerminalStatus | null {
- const root = asJsonRecord(parsed);
- // An untyped bare error envelope (#8216) is classified like the `error` event it stands for.
- const type = root && isBareUpstreamErrorEvent(root) ? "error" : root?.type;
+ const type = asJsonRecord(parsed)?.type;
switch (type) {
case "response.completed":
return "completed";
@@ -1734,10 +1719,8 @@ export function consumeForInspection(
logCtx,
onCompletedResponse,
onParsedPayload: payload => {
- // Same extraction as the client boundary, so a refusal or code sent without a message is
- // still a failure here and cannot be recorded as an incomplete (non-failed) turn.
- const bare = boundedBareUpstreamError(payload);
- if (bare !== undefined) bareUpstreamError = bare.message;
+ const message = boundedBareUpstreamErrorMessage(payload);
+ if (message !== undefined) bareUpstreamError = message;
options?.onParsedPayload?.(payload);
},
onFirstOutput,
diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts
index 1df234bd3a2..a306ee4aadd 100644
--- a/src/server/ws-bridge.ts
+++ b/src/server/ws-bridge.ts
@@ -210,23 +210,6 @@ function payloadType(payload: string): string | null {
}
}
-/**
- * An untyped `{"error":{...}}` envelope (#8216): not a Responses event, but not a malformed frame
- * either. The SSE relay boundary keeps the error and reports it as `response.failed` at EOF unless a
- * real terminal arrives first, so the pump skips the frame and lets that terminal reach the client.
- * Mirrors the untyped half of `isBareUpstreamErrorEvent` in `relay.ts`.
- */
-function isUntypedErrorEnvelope(payload: string): boolean {
- try {
- const json = JSON.parse(payload) as unknown;
- if (!json || typeof json !== "object" || Array.isArray(json)) return false;
- const root = json as { type?: unknown; error?: unknown };
- return root.type === undefined && !!root.error && typeof root.error === "object" && !Array.isArray(root.error);
- } catch {
- return false;
- }
-}
-
function terminalStatusFromType(type: string): ResponsesTerminalStatus | null {
switch (type) {
case "response.completed":
@@ -307,7 +290,6 @@ export async function pumpResponsesSseToWebSocket(
/* payload observation must not affect WebSocket delivery */
}
const type = payloadType(payload);
- if (!type && isUntypedErrorEnvelope(payload)) return false;
if (!type) {
reportTerminal("incomplete");
sendProtocolError(ws, 502, "Invalid JSON payload in upstream SSE frame");
diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md
index e35b2f85bc6..6ec71fb190e 100644
--- a/structure/transports/responses-combo-boundaries.md
+++ b/structure/transports/responses-combo-boundaries.md
@@ -149,10 +149,8 @@ is not a prelude: it commits.
the ordinary bounded combo-failure classifier and may advance to the next declared target;
- a zero-output `response.incomplete` with no reason, `adapter_eof`, `missing_terminal_event`, or
`upstream_stall_timeout` follows the same failure path; explicit semantic or unknown reasons stay terminal;
-- a top-level `error` (`type: "error"`) arrives before output, in which case unknown, rate-limit, and server failures
- may advance while errors explicitly classified as non-retryable 4xx remain committed. An untyped
- `{"error":{...}}` envelope is not a control event, so it commits the target; the relay then reports it
- as `response.failed` rather than `adapter_eof`;
+- a top-level `error` arrives before output, in which case unknown, rate-limit, and server failures
+ may advance while errors explicitly classified as non-retryable 4xx remain committed;
- another completed/incomplete terminal or the aggregate preflight byte or retained-chunk cap is reached,
in which case the current target is committed conservatively.
diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md
index 2bd79832c85..5f11fd37224 100644
--- a/structure/transports/responses-wire-shapes.md
+++ b/structure/transports/responses-wire-shapes.md
@@ -397,14 +397,14 @@ Native passthrough SSE has TWO shapes, selected per request in
The shared `createSseInspector` factory in `relay.ts` retains the complete inspection side effects,
including the #44 late-terminal semantics.
-Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`: a `type: "error"` event, or an envelope with no root `type` that carries an `error` object (#8216). A typed event of any other kind is never bare, even when it has an `error` field. The SSE-to-WebSocket pump in `src/server/ws-bridge.ts` skips the untyped envelope rather than failing it as an invalid frame, so the boundary's terminal still reaches a WebSocket client; any other frame without a string `type` stays a protocol error. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) overload codes (`server_is_overloaded`, `overloaded_error`), and the client-refusal classes `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error` (400/401/403/404, the code repeating the class); recognized types apply only without an explicit code.
-At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code or error class without a
-message still produces a terminal (a refusal code gets refusal copy, any other identifier is named in the message). Codex retries codes outside its fatal set, so code and message follow the same candidate precedence;
+Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) overload codes (`server_is_overloaded`, `overloaded_error`), and the client-refusal classes `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error` (400/401/403/404, the code repeating the class); recognized types apply only without an explicit code.
+At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code without a message still
+produces a terminal. Codex retries codes outside its fatal set, so code and message follow the same candidate precedence;
recognized refusal copy is used only when the event has no code. A read failure after refusal reports that refusal (#5176).
The shared outbound rewrite masks diagnostics on real failed and incomplete terminals before SSE or buffered JSON delivery,
while preserving status and output; failed turns are not retained as continuation state. Buffered JSON masks selected credentials in synthetic bare-error fields before log inspection or client formatting; request logs keep transport status.
The delivering reader owns refusal evidence before EOF; asynchronous tee inspection cannot reliably supply it. The client ingress in `src/server/index/websocket-handler.ts` preserves the same in-process replay-refusal marker as the HTTP deferred logger before finalization: a synthetic 429 retains `upstream_reset_replay_refused` and `transport-ambiguous` on the row and serving attempt, with `terminalSource: synthetic` on the row. `src/server/responses/core-combo-failure.ts` carries that marker through child failure rebuilds, and the combo loop stamps the failed child attempt before adopting its log. Synthetic refusals stop before combo cooldown/account rotation and supply no quota reset or cooldown metadata. Genuine upstream 429s keep rate-limit attribution and health handling. This is independent of the upstream transport choice; `headers-only` does not prove upstream execution or quota exhaustion. `tests/responses/ws-replay-refusal-attribution.test.ts` checks direct routes; `tests/responses/combo-replay-refusal-attribution.test.ts` checks both combo ingresses, gateway and pool children, health/cooldown state and genuine 429 controls, with one send and no recovery.
-Inspection still applies the same bare-error extraction at EOF for account health, so a refusal or code sent without a message is reported `failed` there too. Real terminals and caller cancellation take precedence. Unknown codes retain `upstream_error` / `upstream_server_error` in the client tail; on the streaming path the log status takes the frame's Codex WebSocket wrapper 5xx when stated (buffered JSON callers keep their own presentation status). Refusal precedence is unchanged and classification adds no replay or retry. Bare-error log status is provisional until a genuine terminal supersedes it through `src/server/request-log-terminal-status.ts`.
+Inspection still applies the bare-error rule at EOF for account health. Real terminals and caller cancellation take precedence. Unknown codes retain `upstream_error` / `upstream_server_error` in the client tail; on the streaming path the log status takes the frame's Codex WebSocket wrapper 5xx when stated (buffered JSON callers keep their own presentation status). Refusal precedence is unchanged and classification adds no replay or retry. Bare-error log status is provisional until a genuine terminal supersedes it through `src/server/request-log-terminal-status.ts`.
Native recovery preflight keeps the rejected body reader and bounded prefix for normal mid-stream failure, without decrypt retry.
Native Responses may rebuild once when encrypted function/custom-tool output or
@@ -517,7 +517,7 @@ byte-identical payloads unchanged.
The client-facing boundary treats the first Responses terminal as authoritative in both relay
shapes. High-confidence policy errors carried as `response.incomplete`, `response.failed`, or a
-bare `error` (typed or untyped) are normalized to one `response.failed / cyber_policy` event without changing the
+top-level `error` are normalized to one `response.failed / cyber_policy` event without changing the
refusal outcome; later bytes cannot create a second terminal. A clean HTTP 200 EOF with no terminal
instead emits one `response.incomplete` with `adapter_eof`, followed by one `[DONE]`. Delimiter-less
EOF candidates follow the owning repair policy: the native boundary accepts a structurally valid
diff --git a/tests/responses/sse-failed-tail.test.ts b/tests/responses/sse-failed-tail.test.ts
index b439b3905b5..bc3975cf661 100644
--- a/tests/responses/sse-failed-tail.test.ts
+++ b/tests/responses/sse-failed-tail.test.ts
@@ -9,8 +9,6 @@ import { readCodexWsStage } from "../../src/server/responses/codex-ws-wire";
import { TERMINAL_REFUSAL_FALLBACK_MESSAGE } from "../../src/lib/errors";
import { TranslatorBudgetExceededError } from "../../src/lib/translator-budget";
import { createOutboundCredentialMask } from "../../src/server/responses/terminal-error-redaction";
-import { pumpResponsesSseToWebSocket, type WsData } from "../../src/server/ws-bridge";
-import type { ServerWebSocket } from "bun";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -449,117 +447,6 @@ describe("relaySseWithFailedTail", () => {
expect(doneEvents(out)).toHaveLength(1);
expect(out).not.toContain("event: response.failed");
});
-
- /** Client relay output plus the terminal the tee inspection branch or eager hooks reported. */
- async function relayAndInspect(mode: "tee" | "eager", raw: string): Promise<{ out: string; reported: string[] }> {
- const reported: string[] = [];
- const src = sourceStream([raw]);
- if (mode === "eager") {
- const hooks = { ...parityHooks, onSynthetic: (kind: string) => { reported.push(kind); } };
- return { out: await drain(relaySseEagerBounded(src, new AbortController(), hooks)), reported };
- }
- const [client, inspection] = src.tee();
- const log: RequestLogContext = { model: "fixture-model", provider: "openai" };
- const inspected = new Promise(resolve => consumeForInspection(inspection, status => { reported.push(status); }, undefined, resolve, log));
- const out = await drain(relaySseWithFailedTail(client, new AbortController()));
- await inspected;
- return { out, reported };
- }
-
- test.each(["tee", "eager"] as const)("%s untyped error envelope ends in response.failed, not adapter_eof (#8216)", async (mode) => {
- // Captured gateway shape: heartbeat, an `error` object with no root `type`, DONE, EOF.
- const raw = ": PING\n\n"
- + 'data: {"error":{"message":"openai_error","type":"bad_response_status_code","param":"","code":"bad_response_status_code"}}\n\n'
- + "data: [DONE]\n\n";
- const { out, reported } = await relayAndInspect(mode, raw);
-
- expect(failedMessage(out)).toBe("openai_error");
- expect(out).toContain('"code":"upstream_server_error"');
- expect(terminalEvents(out)).toEqual(["response.failed"]);
- expect(doneEvents(out)).toHaveLength(1);
- expect(out).not.toContain("adapter_eof");
- expect(reported).toEqual(["failed"]);
- });
-
- test.each(["tee", "eager"] as const)("%s typed event carrying an error field is not a bare error (#8216)", async (mode) => {
- const raw = 'data: {"type":"response.in_progress","response":{"status":"in_progress"},'
- + '"error":{"message":"not a failure","code":"server_error"}}\n\n';
- const { out, reported } = await relayAndInspect(mode, raw);
-
- expect(terminalEvents(out)).toEqual(["response.incomplete"]);
- expect(out).toContain('"reason":"adapter_eof"');
- expect(out).not.toContain("event: response.failed");
- expect(doneEvents(out)).toHaveLength(1);
- expect(reported).toEqual(["incomplete"]);
- });
-
- test.each(["tee", "eager"] as const)("%s untyped refusal code without a message reports failed on both readers (#8216)", async (mode) => {
- // The client tail already carried the refusal; tee inspection used to read only the message and
- // reported `incomplete`, which account health counts as a non-failure.
- const raw = 'data: {"error":{"code":"invalid_prompt"}}\n\n';
- const { out, reported } = await relayAndInspect(mode, raw);
-
- expect(terminalEvents(out)).toEqual(["response.failed"]);
- expect(out).toContain('"code":"invalid_prompt"');
- expect(failedMessage(out)).toBe(TERMINAL_REFUSAL_FALLBACK_MESSAGE);
- expect(reported).toEqual(["failed"]);
- });
-
- test.each([
- ["tee", "untyped"], ["eager", "untyped"], ["tee", "typed"], ["eager", "typed"],
- ] as const)("%s %s error with a code but no message still ends in response.failed", async (mode, shape) => {
- const raw = shape === "untyped"
- ? 'data: {"error":{"code":"bad_response_status_code"}}\n\n'
- : 'data: {"type":"error","code":"bad_response_status_code"}\n\n';
- const { out, reported } = await relayAndInspect(mode, raw);
-
- expect(terminalEvents(out)).toEqual(["response.failed"]);
- expect(out).not.toContain("adapter_eof");
- expect(out).toContain('"code":"upstream_server_error"');
- // The upstream's own code is the only diagnostic it sent; keep it in the message.
- expect(failedMessage(out)).toContain("bad_response_status_code");
- expect(doneEvents(out)).toHaveLength(1);
- expect(reported).toEqual(["failed"]);
- });
-
- test.each(["tee", "eager"] as const)("%s untyped cyber_policy error is rewritten as the policy terminal (#8216)", async (mode) => {
- const raw = 'data: {"error":{"type":"invalid_request_error","code":"cyber_policy","message":"blocked by policy"}}\n\n'
- + 'data: {"type":"response.output_text.delta","delta":"late"}\n\n';
- const { out, reported } = await relayAndInspect(mode, raw);
-
- expect(terminalEvents(out)).toEqual(["response.failed"]);
- expect(out).not.toContain('data: {"error":');
- expect(out).not.toContain("late");
- expect(failedMessage(out)).toBe("blocked by policy");
- expect(out).toContain('"code":"cyber_policy"');
- expect(out).toContain('"retryable":false');
- expect(doneEvents(out)).toHaveLength(1);
- // The rewritten frame is a real terminal: tee inspection reports it, and eager synthesizes none.
- expect(reported).toEqual(mode === "tee" ? ["failed"] : []);
- });
-
- test.each(["tee", "eager"] as const)("%s untyped error envelope reaches a WebSocket client as response.failed (#8216)", async (mode) => {
- const raw = 'data: {"type":"response.created","response":{"id":"r1","status":"in_progress"}}\n\n'
- + 'data: {"error":{"message":"openai_error","code":"bad_response_status_code"}}\n\n'
- + "data: [DONE]\n\n";
- const src = sourceStream([raw]);
- const relayed = mode === "tee"
- ? relaySseWithFailedTail(src, new AbortController())
- : relaySseEagerBounded(src, new AbortController(), parityHooks);
- const sent: string[] = [];
- const terminals: string[] = [];
- const ws = {
- readyState: 1,
- data: {} as WsData,
- send(message: string) { sent.push(message); return 1; },
- } as unknown as ServerWebSocket;
- await pumpResponsesSseToWebSocket(ws, relayed, { onTerminal: status => { terminals.push(status); } });
-
- const types = sent.map(frame => (JSON.parse(frame) as { type?: unknown }).type);
- expect(types).toEqual(["response.created", "response.failed"]);
- expect((JSON.parse(sent[1]!) as { response: { error: { message: string } } }).response.error.message).toBe("openai_error");
- expect(terminals).toEqual(["failed"]);
- });
});
diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts
index 5966272c2ef..fc2c62702fa 100644
--- a/tests/routing/combo-stream-preflight.test.ts
+++ b/tests/routing/combo-stream-preflight.test.ts
@@ -379,18 +379,6 @@ describe("combo stream preflight", () => {
}
});
- test("commits an untyped error envelope instead of advancing the combo (#8216)", async () => {
- // Not a control event, so it is never resent to another target; the relay reports it as failed.
- const source = sse(
- { type: "response.created", response: { id: "r1", status: "in_progress" } },
- { error: { message: "openai_error", code: "bad_response_status_code" } },
- );
- const expected = await source.clone().text();
- const result = await preflightComboStreamResponse(source, { model: "m1", provider: "a" });
- expect(result.kind).toBe("accepted");
- expect(await result.response.text()).toBe(expected);
- });
-
test("passes a zero-output credential error to the ordinary combo classifier", async () => {
const result = await preflightComboStreamResponse(sse({
type: "error",
From 53d63a349a9cca51556934d991f7b97b97b55d4b Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 03/14] Revert "fix(codex): reclaim stale shim restore lock
with several dead owners (#8349)"
This reverts commit 9fb295bfd26d7e8a992373a69f669aa66ececaa5.
---
src/codex/shim-restore-lock.ts | 97 +++++++---------------
structure/runtime.md | 2 +-
tests/codex-integration/codex-shim.test.ts | 48 -----------
3 files changed, 31 insertions(+), 116 deletions(-)
diff --git a/src/codex/shim-restore-lock.ts b/src/codex/shim-restore-lock.ts
index 1de3f257519..27d1eae8a78 100644
--- a/src/codex/shim-restore-lock.ts
+++ b/src/codex/shim-restore-lock.ts
@@ -32,107 +32,70 @@ interface ShimRestoreLockRecord {
createdAt: number;
}
-interface ShimRestoreLockOwner {
+interface ShimRestoreLockSnapshot {
record: ShimRestoreLockRecord;
ownerPath: string;
- fingerprint: ShimPathFingerprint;
-}
-
-interface ShimRestoreLockSnapshot {
lockIdentity: FileIdentity;
- /** Sorted by owner filename so two snapshots compare entry by entry. */
- owners: ShimRestoreLockOwner[];
+ fingerprint: ShimPathFingerprint;
}
function restoreLockPath(): string {
return join(getConfigDir(), "codex-shim.autorestore.lock");
}
-function readShimRestoreLockOwner(path: string, entry: string): ShimRestoreLockOwner | null {
- if (!entry.endsWith(".json")) return null;
- const ownerPath = join(path, entry);
- const probe = stableShimPathProbe(ownerPath);
- if (!probe || probe.fingerprint.kind !== "file" || probe.fingerprint.size > 4096) return null;
- try {
- const value = JSON.parse(probe.prefix) as Partial;
- if (value.version !== 1 || typeof value.token !== "string" || value.token.length === 0
- || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0
- || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null;
- if (entry !== `${value.token}.json`) return null;
- return { record: value as ShimRestoreLockRecord, ownerPath, fingerprint: probe.fingerprint };
- } catch {
- return null;
- }
-}
-
-/**
- * Every entry must be a valid token-named owner record; one unaccounted entry (foreign file,
- * unparsable or empty record, mismatched name) makes the whole lock unreadable. A normal lock
- * holds one owner. Two or more are not produced by the current acquire path, but have been
- * observed in the field (#8317) and must stay recoverable rather than block repair forever.
- */
function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | null {
let lockIdentity: BigIntStats;
let entries: string[];
try {
lockIdentity = lstatSync(path, { bigint: true });
if (!lockIdentity.isDirectory()) return null;
- entries = readdirSync(path).sort();
+ entries = readdirSync(path);
} catch {
return null;
}
- if (entries.length === 0) return null;
- const owners: ShimRestoreLockOwner[] = [];
- for (const entry of entries) {
- const owner = readShimRestoreLockOwner(path, entry);
- if (!owner) return null;
- owners.push(owner);
- }
+ if (entries.length !== 1 || !entries[0].endsWith(".json")) return null;
+ const ownerPath = join(path, entries[0]);
+ const probe = stableShimPathProbe(ownerPath);
+ if (!probe || probe.fingerprint.kind !== "file" || probe.fingerprint.size > 4096) return null;
try {
+ const value = JSON.parse(probe.prefix) as Partial;
+ if (value.version !== 1 || typeof value.token !== "string" || value.token.length === 0
+ || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0
+ || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null;
+ if (entries[0] !== `${value.token}.json`) return null;
const currentLockIdentity = lstatSync(path, { bigint: true });
if (!currentLockIdentity.isDirectory() || !sameFileIdentity(lockIdentity, currentLockIdentity)) return null;
+ return {
+ record: value as ShimRestoreLockRecord,
+ ownerPath,
+ lockIdentity,
+ fingerprint: probe.fingerprint,
+ };
} catch {
return null;
}
- return { lockIdentity, owners };
-}
-
-/** Release only ever removes a lock it solely owns; an ambiguous set is left to stale recovery. */
-function soleShimRestoreLockOwner(path: string): ShimRestoreLockOwner | null {
- const snapshot = readShimRestoreLockSnapshot(path);
- return snapshot?.owners.length === 1 ? snapshot.owners[0] : null;
}
function sameShimRestoreLock(left: ShimRestoreLockSnapshot, right: ShimRestoreLockSnapshot): boolean {
- return sameFileIdentity(left.lockIdentity, right.lockIdentity)
- && left.owners.length === right.owners.length
- && left.owners.every((owner, index) => owner.record.token === right.owners[index].record.token
- && sameFingerprint(owner.fingerprint, right.owners[index].fingerprint));
+ return left.record.token === right.record.token
+ && sameFileIdentity(left.lockIdentity, right.lockIdentity)
+ && sameFingerprint(left.fingerprint, right.fingerprint);
}
-/**
- * Reclaim requires every owner record to be past the stale window and to name a dead PID.
- * Any live, fresh or unaccounted entry keeps the lock: age alone never evicts a live holder,
- * and an entry this code cannot attribute might belong to an owner it cannot judge.
- */
function reclaimStaleRestoreLock(path: string, beforeDelete?: () => void): boolean {
const observed = readShimRestoreLockSnapshot(path);
if (!observed) return false;
- const now = Date.now();
- for (const owner of observed.owners) {
- const createdAt = Math.max(owner.record.createdAt, owner.fingerprint.mtimeMs);
- if (now - createdAt <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false;
- if (isProcessAlive(owner.record.pid)) return false;
- }
+ const createdAt = Math.max(observed.record.createdAt, observed.fingerprint.mtimeMs);
+ if (Date.now() - createdAt <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false;
+ if (isProcessAlive(observed.record.pid)) return false;
const current = readShimRestoreLockSnapshot(path);
if (!current || !sameShimRestoreLock(observed, current)) return false;
beforeDelete?.();
try {
- // The token is part of each owner filename. Even if the lock directory is
- // replaced after the comparison, these unlinks cannot target a successor's
- // differently named owner record. A failure part-way leaves a smaller set
- // of the same dead owners, which the next attempt reclaims.
- for (const owner of observed.owners) unlinkSync(owner.ownerPath);
+ // The token is part of the owner filename. Even if the lock directory is
+ // replaced after the comparison, this unlink cannot target a successor's
+ // differently named owner record.
+ unlinkSync(observed.ownerPath);
rmdirSync(path);
return true;
} catch {
@@ -169,7 +132,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL
released = true;
try { closeSync(fd!); } catch { /* stale recovery handles an uncertain lock */ }
try {
- const current = soleShimRestoreLockOwner(path);
+ const current = readShimRestoreLockSnapshot(path);
if (identity && current && current.record.token === record.token
&& sameFileIdentity(identity, current.fingerprint.identity)) {
unlinkSync(ownerPath);
@@ -182,7 +145,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL
if (fd !== null) {
try { closeSync(fd); } catch { /* best-effort close before ownership cleanup */ }
try {
- const current = soleShimRestoreLockOwner(path);
+ const current = readShimRestoreLockSnapshot(path);
if (identity && current && current.record.token === record.token
&& sameFileIdentity(identity, current.fingerprint.identity)) {
unlinkSync(ownerPath);
diff --git a/structure/runtime.md b/structure/runtime.md
index 95ad7fb7297..7e6c538a3d2 100644
--- a/structure/runtime.md
+++ b/structure/runtime.md
@@ -293,7 +293,7 @@ atomic OS guarantee against unobserved PID reuse.
An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. `ocx codex` skips repair and owns handoff: unknown Windows CMD wrappers require complete bypass-aware template equality, otherwise launch is refused; content equality grants no Windows backing provenance. Other changing launchers defer and repair failures warn without changing their command's exit behavior.
All mutations hold the shared atomic-mkdir interprocess lock and reread state before final revalidation, publication and state commit. Unique token-named owner records prevent release from naming a successor.
-Aged locks are reclaimed only when every entry is a valid token-named owner record, every owner is past the stale window with a dead PID, and the same directory identity, token set and owner fingerprints remain immediately before deletion; reclaim unlinks each owner by its token name, then the directory. Age alone never evicts a live holder, and an unaccounted entry blocks reclaim. A directory holding several dead owners (#8317) is reclaimed the same way; release still removes only a lock its caller solely owns.
+Aged locks are reclaimed only when the owner PID is dead and the same token, directory identity and owner fingerprint remain immediately before deletion; age alone never evicts a live holder.
Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. Windows service/shim writes first admit the frozen selected runtime under the [write-preflight contract](ops/service-and-sidecars.md#windows-selected-runtime-write-preflight); orchestrated installs refuse Desktop supervision before selection and admit once before cleanup. Repair refuses Desktop before diagnosis, then checks ownership and auth before admission and any native repair or scheduler stop. The Node launcher’s validated PATH Bun fallback precedes CLI startup; durable admission never discovers a replacement. The fresh scheduler path claims config ownership after registration and staging removal, then admits before `prepare()`; refusal rolls back the new registration and preserves the claimed config root. Standalone admission uses the current executable in-process, without child isolation or a timeout.
Unix installation owns only the private home/bin/codex wrapper and codex-shell-env.sh. The native manager-owned launcher stays in place; its recorded durable entry follows package upgrades and version rollback.
`src/codex/shim-state-file.ts` decodes schema 2, mode `path-overlay`, supported Unix platform, exact private `wrapperPath`, and distinct absolute `launcherPath`; contradictory compatibility fields are invalid. State can record wrapper/env inode identities and a transaction id. `src/codex/shim-overlay.ts` compares persisted wrapper/environment ownership by inode, allowing APFS device numbers to change across reboot. Exact private paths, current-user ownership, regular-file checks and group/world-write restrictions, wrapper marker and recorded launcher binding, and exact environment content remain required. Runnable wrappers also require current generated content and a usable native launcher. Inspection snapshots, namespace revalidation, activation and publication/recovery identities still compare device plus inode to refuse concurrent replacements. `tests/codex-integration/codex-shim-overlay.test.ts` covers persisted device drift and inode/content refusals; `tests/codex-integration/codex-shim-overlay-transaction.test.ts` covers same-inspection device drift and concurrent publication.
diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts
index 3361ad40e6b..fc7819b90a7 100644
--- a/tests/codex-integration/codex-shim.test.ts
+++ b/tests/codex-integration/codex-shim.test.ts
@@ -1858,54 +1858,6 @@ exit 127
});
});
- // #8317: a lock directory holding several aged owner records used to be unreadable, so it was never reclaimed.
- function seedAgedLockEntries(lockPath: string, pids: number[], extra: string[] = []): string[] {
- mkdirSync(lockPath);
- pids.forEach((pid, index) => {
- const ownerPath = join(lockPath, `aged-owner-${index}.json`);
- writeFileSync(ownerPath, `${JSON.stringify({ version: 1, token: `aged-owner-${index}`, pid, createdAt: 0 })}\n`, "utf8");
- utimesSync(ownerPath, new Date(0), new Date(0));
- });
- for (const name of extra) {
- writeFileSync(join(lockPath, name), "unaccounted\n", "utf8");
- utimesSync(join(lockPath, name), new Date(0), new Date(0));
- }
- return readdirSync(lockPath).sort();
- }
-
- test("an aged lock whose owner records all name dead pids is reclaimed", () => {
- withInstalledShim(({ home, wrappers }) => {
- const lockPath = join(home, "codex-shim.autorestore.lock");
- wrappers.forEach((path, index) => process.platform === "win32"
- ? writeFileSync(path, successfulLauncher(`two-dead-owners-${index}`), "utf8") : chmodSync(path, 0o644));
- seedAgedLockEntries(lockPath, [2_147_483_647, 2_147_483_646]);
-
- expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("restored");
- expect(existsSync(lockPath)).toBe(false);
- wrappers.forEach(path => expect(readFileSync(path, "utf8")).toContain(SHIM_MARKER));
- });
- });
-
- for (const [label, pids, extra] of [
- ["one live owner", [2_147_483_647, process.pid], []],
- ["an unaccounted entry", [2_147_483_647, 2_147_483_646], ["stray.tmp"]],
- ] as const) {
- test(`an aged multi-owner lock with ${label} is never reclaimed`, () => {
- withInstalledShim(({ home, wrappers }) => {
- const lockPath = join(home, "codex-shim.autorestore.lock");
- const oldWrappers = wrappers.map(path => readFileSync(path));
- wrappers.forEach((path, index) => process.platform === "win32"
- ? writeFileSync(path, `replacement-${index}\n`, "utf8") : chmodSync(path, 0o644));
- const entries = seedAgedLockEntries(lockPath, [...pids], [...extra]);
-
- expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait })).toEqual({ status: "deferred" });
- expect(readdirSync(lockPath).sort()).toEqual(entries);
- wrappers.forEach((path, index) => process.platform === "win32"
- ? expect(readFileSync(path, "utf8")).toBe(`replacement-${index}\n`) : expect(readFileSync(path)).toEqual(oldWrappers[index]));
- });
- });
- }
-
test("stalled partial write changing during the observation interval is never promoted", () => {
withInstalledShim(({ wrappers, backups }) => {
if (process.platform !== "win32") {
From 0afbf18190733c828eb4fb8bea376bc997a02e71 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 04/14] Revert "docs(providers): add custom gateway
verification guide (#8350)"
This reverts commit d38d2e316d3afaf6d09caeb2dea916a97228c29b.
---
.../src/content/docs/guides/custom-gateway.md | 90 -------------------
.../src/content/docs/guides/providers.md | 2 -
.../content/docs/ko/guides/custom-gateway.md | 85 ------------------
.../src/content/docs/ko/guides/providers.md | 1 -
4 files changed, 178 deletions(-)
delete mode 100644 docs-site/src/content/docs/guides/custom-gateway.md
delete mode 100644 docs-site/src/content/docs/ko/guides/custom-gateway.md
diff --git a/docs-site/src/content/docs/guides/custom-gateway.md b/docs-site/src/content/docs/guides/custom-gateway.md
deleted file mode 100644
index 1b298fb0928..00000000000
--- a/docs-site/src/content/docs/guides/custom-gateway.md
+++ /dev/null
@@ -1,90 +0,0 @@
----
-title: Verify a custom remote gateway
-description: Check a custom Chat Completions gateway from discovery through a bounded completion.
----
-
-Before registering a gateway, obtain its authorized Chat Completions base URL, API key, and
-supported model IDs from its operator. For example, `https://api.example.com/v1` is a placeholder,
-not a working endpoint. Start the proxy if it is not already running, then choose **Custom** in the
-dashboard, name it `my-gateway`, select `openai-chat`, and enter the authorized URL and key in the
-form. Save it without putting the key in commands or shared examples. A saved provider is not yet
-proof that discovery or inference works.
-
-Check the boundaries separately, using the configured local port if it differs from the default:
-
-```sh
-curl -fsS http://127.0.0.1:10100/healthz
-ocx ready
-ocx provider test my-gateway
-ocx models live --provider my-gateway --json
-```
-
-Health and readiness describe the proxy; they do not prove the upstream can generate text. The
-provider test without `--model` checks discovery, not inference. `applicable: false` can mean there
-is no live discovery endpoint for a static catalog; it is not a successful inference test. A public
-model list also need not validate the key. If discovery fails or no model appears, check the URL,
-credentials, upstream access, and provider configuration before selecting a model.
-
-A gateway can serve completions without a model-list endpoint. If its operator supplies a supported
-raw ID, configure `liveModels: false` and `models: ["vendor/model"]` on the existing provider,
-preserving its other fields and replacing the example ID with the supplied value. See
-[static model catalogs](/reference/configuration/providers/#static-model-allowlists).
-
-Before editing `config.json` directly, save your work and wait for active requests to finish;
-stopping the proxy can interrupt them. Stop the proxy through its owner: quit OpenCodex Desktop
-from its tray, run `ocx service stop` for a service install, or run `ocx stop` for an unsupervised
-proxy. Confirm it is stopped, then edit and save the file without running other configuration
-writers. Start it again through the same owner: reopen Desktop, run `ocx service start`, or run the
-original `ocx start` command with its launch options, including `--port` if supplied. For a
-foreground start, leave it running in its terminal and run `ocx sync` and the remaining checks from
-another terminal. Then inspect the live catalog again. Alternatively, test the supplied raw ID directly
-with the cost-confirmed completion command below; that test does not require a catalog row.
-
-In the live catalog inspect the actual `id`, `namespaced`, `disabled`, and
-`initialSelectionPending` fields. Initial selection can leave models off or pending; see
-[initial model selection](/reference/configuration/providers/#initial-model-selection).
-Resolve pending initial selection before enabling a row. For visibility commands, combine the
-provider name with the row's **raw `id`**, preserving any slashes inside that ID:
-
-```sh
-ocx models enable 'my-gateway/'
-```
-
-For example, a raw `id` of `vendor/model` needs the selector `my-gateway/vendor/model`.
-The client-facing `namespaced` slug may instead be `my-gateway/vendor-model`; substituting that
-encoded slug can leave a raw disabled entry in place. Enabling one model does not disable others.
-If only that model should be visible from this gateway, disable each other enabled gateway row
-using its raw `id` in the same selector format, then inspect the list again:
-
-```sh
-ocx models disable 'my-gateway/'
-ocx models live --provider my-gateway --json
-```
-
-Check that the intended row has `disabled: false` and the other intended exclusions are disabled.
-A bare native model ID can select a different route. Use exact values rather than display names or
-guessed reasoning suffixes: a suffix may be a distinct upstream model ID, while reasoning effort
-is a separate setting. See the [CLI reference](/reference/cli/providers-accounts/) for details.
-
-When needed, run `ocx sync` to update the client catalog, then confirm the model appears in the
-Codex model picker. A running client can retain an older in-memory list. Preserve active work and,
-if the picker remains stale, fully quit and reopen Codex. Alternatively, after explicit approval,
-use [`ocx system codex-restart --yes`](/reference/cli/agents/): it can
-interrupt the session and discard unsaved drafts, selections, or pending approval prompts.
-Outside the direct config-file edit above, restarting a client or proxy is not the default recovery step.
-
-To verify one actual completion on a supported key-auth remote configuration, pass the **raw
-upstream `id`** from the row or gateway operator unchanged, without adding the provider prefix:
-
-```sh
-ocx provider test my-gateway --model ''
-```
-
-This sends one bounded request, consumes quota, and may incur charges. Confirm the default-No prompt
-only after approving that cost; non-interactive or JSON use requires `--yes`. An
-`applicable: false` completion result sends no completion and is not a pass, even if the command
-exits zero. Before counting a successful test as a pass, check that the reported wire model
-matches the intended upstream ID; an alias can resolve to a different model. In JSON output this
-is `wireModel`. A successful text completion does not establish tool calling, structured output,
-continuation behavior, or the model's actual identity; upstream-reported identity is metadata. For
-later compatibility issues, reproduce the affected protocol separately with a sanitized fixture.
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 635150ee7e3..0a47196a510 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -1835,8 +1835,6 @@ references are preserved. Flattening is lossy: branch constraints
disappear and later property definitions win, so tools must validate arguments. Existing Zen and
Azure schema compatibility rules remain in effect.
-See [Verify a custom remote gateway](/guides/custom-gateway/) for the setup and validation checklist.
-
## Approval reviewer per provider
Codex asks a second model to review approval requests, and takes that reviewer from
diff --git a/docs-site/src/content/docs/ko/guides/custom-gateway.md b/docs-site/src/content/docs/ko/guides/custom-gateway.md
deleted file mode 100644
index 82cfd75dbc2..00000000000
--- a/docs-site/src/content/docs/ko/guides/custom-gateway.md
+++ /dev/null
@@ -1,85 +0,0 @@
----
-title: 사용자 지정 원격 게이트웨이 검증
-description: 모델 목록 조회부터 제한된 응답 생성까지 사용자 지정 게이트웨이를 확인합니다.
----
-
-등록 전에 운영자로부터 사용이 허가된 Chat Completions 베이스 URL, API 키, 지원 모델 ID를 확보하세요.
-예를 들어 `https://api.example.com/v1`은 실제 엔드포인트가 아닌 자리표시자입니다. 프록시가 꺼져 있다면
-시작한 다음 대시보드에서 **Custom**을 선택하고 이름을 `my-gateway`, 어댑터를 `openai-chat`으로 정한 뒤
-허가된 URL과 키를 입력하고 저장하세요. 키를 명령이나 공유 예시에 넣지 마세요. 저장 성공만으로 모델 목록 조회나
-추론 성공이 증명되지는 않습니다.
-
-각 단계를 따로 확인하세요. 기본 포트가 아니면 설정한 로컬 포트를 사용합니다.
-
-```sh
-curl -fsS http://127.0.0.1:10100/healthz
-ocx ready
-ocx provider test my-gateway
-ocx models live --provider my-gateway --json
-```
-
-Health와 readiness는 프록시 상태이며 업스트림의 텍스트 생성 성공을 뜻하지 않습니다. `--model` 없는
-provider test는 추론이 아닌 모델 목록 조회를 검사합니다. 정적 카탈로그에 실시간 모델 조회 엔드포인트가 없으면
-`applicable: false`가 나올 수 있으며 이는 추론 성공이 아닙니다. 공개 모델 목록의 성공도 키 유효성을
-증명하지 않을 수 있습니다. 모델 목록 조회가 실패하거나 모델이 없다면 선택 전에 URL, 자격 증명, 업스트림 접근 권한과
-프로바이더 설정을 확인하세요.
-
-게이트웨이는 모델 목록 엔드포인트가 없어도 응답을 생성할 수 있습니다. 운영자가 지원하는 원본 ID를 제공했다면
-기존 프로바이더의 다른 필드는 유지하고 `liveModels: false`, `models: ["vendor/model"]`을 설정하세요.
-예시 ID는 제공받은 정확한 값으로 바꿉니다. [정적 모델 설정](/ko/reference/configuration/providers/)을 참고하세요.
-
-`config.json`을 직접 수정하기 전에 작업을 저장하고 진행 중인 요청이 끝날 때까지 기다리세요. 프록시를 중지하면
-진행 중인 요청이 중단될 수 있습니다. 관리 주체에 따라 Desktop 트레이에서 OpenCodex를 종료하거나, 서비스 설치는
-`ocx service stop`, 감독되지 않는 프록시는 `ocx stop`으로 중지하세요. 중지된 것을 확인한 뒤 다른 설정 쓰기 작업을
-실행하지 않는 상태에서 파일을 수정하고 저장합니다. 같은 관리 주체로 다시 시작하세요: Desktop을 다시 열거나,
-`ocx service start`를 실행하거나, 기존 `ocx start` 명령과 실행 옵션을 사용합니다. 직접 지정한 `--port`도
-포함합니다. 포그라운드 시작 명령은 해당 터미널에서 계속 실행하고 다른 터미널에서 `ocx sync`와 나머지 검사를
-실행하세요. 그다음 live 카탈로그를 다시 확인합니다. 또는 아래 비용 확인을 거치는 응답 생성 명령에
-제공받은 원본 ID를 직접 사용할 수 있습니다. 이 테스트에는 카탈로그 행이 필요하지 않습니다.
-
-live 카탈로그에서 실제 `id`, `namespaced`, `disabled`, `initialSelectionPending`을 확인하세요.
-초기 선택에 따라 모델이 꺼져 있거나 대기 상태일 수 있습니다. [초기 모델 선택](/ko/reference/configuration/providers/)을
-참고하세요. 행을 켜기 전에 초기 선택의 대기 상태를 해소합니다. 표시 관리 명령의 선택자는 프로바이더 이름과 행의
-**원본 `id`**를 조합하며, ID 내부의 슬래시를 그대로 유지합니다.
-
-```sh
-ocx models enable 'my-gateway/<원본-업스트림-ID>'
-```
-
-예를 들어 원본 `id`가 `vendor/model`이면 선택자는 `my-gateway/vendor/model`입니다.
-클라이언트용 `namespaced` 값은 `my-gateway/vendor-model`처럼 인코딩될 수 있습니다. 이를 대신 사용하면
-원본 ID로 저장된 비활성 항목이 해제되지 않을 수 있습니다. 모델 하나를 켜도 다른 모델은 꺼지지 않습니다.
-이 게이트웨이에서 해당 모델만 표시하려면 같은 선택자 형식에 각 행의 원본 `id`를 넣어 다른 활성 모델을 끄고
-목록을 다시 확인하세요.
-
-```sh
-ocx models disable 'my-gateway/<다른-원본-업스트림-ID>'
-ocx models live --provider my-gateway --json
-```
-
-원하는 행은 `disabled: false`이고 제외하려는 다른 행은 비활성 상태인지 확인하세요. 접두사 없는 네이티브 모델 ID는
-다른 경로를 선택할 수 있습니다. 표시 이름이나 추측한 reasoning 접미사 대신 정확한 값을 쓰세요. 접미사는 별도
-업스트림 모델 ID일 수 있으며 reasoning 강도는 별도 설정입니다. 자세한 내용은
-[CLI 레퍼런스](/ko/reference/cli/providers-accounts/)를 참고하세요.
-
-필요하면 `ocx sync`로 클라이언트 카탈로그에 반영한 뒤 Codex 모델 선택기에 해당 모델이 나타나는지 확인하세요.
-실행 중인 클라이언트는 이전 목록을 메모리에 유지할 수 있습니다. 진행 중인 작업을 보존하고 목록이 계속 오래된 상태라면
-Codex를 완전히 종료한 뒤 다시 여세요. 또는 명시적으로 승인한 뒤
-[`ocx system codex-restart --yes`](/ko/reference/cli/agents/)를 사용할 수 있습니다.
-이 명령은 세션을 중단하고 저장하지 않은 초안, 선택 또는 대기 중인 승인 요청을 잃게 할 수 있습니다.
-위의 설정 파일 직접 수정 외에는 클라이언트나 프록시 재시작이 기본 복구 단계가 아닙니다.
-
-지원되는 키 인증 원격 설정에서 실제 응답 생성 요청 하나를 검증하려면 행이나 게이트웨이 운영자로부터 얻은
-**업스트림 원본 `id`**를 프로바이더 접두사를 추가하지 않고 그대로 사용합니다.
-
-```sh
-ocx provider test my-gateway --model '<원본-업스트림-ID>'
-```
-
-이 명령은 제한된 요청 하나를 보내며 쿼터를 사용하고 요금이 발생할 수 있습니다. 비용을 승인한 뒤에만 기본값이
-No인 확인 질문에 동의하세요. 비대화형 또는 JSON 사용에는 `--yes`가 필요합니다. 응답 생성 테스트의
-`applicable: false`는 요청을 보내지 않았다는 뜻이며 종료 코드가 0이어도 통과가 아닙니다. 성공한 테스트를 통과로
-판정하기 전에 보고된 wire 모델이 의도한 업스트림 ID와 일치하는지 확인하세요. 별칭이 다른 모델로 해석될 수 있습니다.
-JSON 출력에서는 `wireModel`입니다. 텍스트 생성 성공
-한 번은 도구 호출, 구조화 출력, 연속 호출 또는 모델의 실제 정체를 증명하지 않습니다. 업스트림이 보고한
-모델명은 메타데이터입니다. 이후 호환성 문제는 비식별 fixture로 해당 프로토콜을 따로 재현하세요.
diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md
index 3c94de912b6..aa3b3f3aff1 100644
--- a/docs-site/src/content/docs/ko/guides/providers.md
+++ b/docs-site/src/content/docs/ko/guides/providers.md
@@ -561,7 +561,6 @@ opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은
(`headers`, `noReasoningModels`, `noVisionModels`, `models`, …)는
[설정 레퍼런스](/ko/reference/configuration/)를 참고하세요.
-설정과 검증 단계는 [사용자 지정 원격 게이트웨이 검증](/ko/guides/custom-gateway/)을 참고하세요.
### Antigravity 쿼터 조회 실패 확인
From b010db3388a220fe5fff94052a018873a12d79d9 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 05/14] Revert "fix(integrations): leave an uninstalled owned
client alone on implicit refresh (#8353)"
This reverts commit 0a1d59e5f75b6a5809f61f196415938b1bced5b0.
---
src/integrations/owned-refresh.ts | 11 ++---------
tests/clients/sync-client-integrations.test.ts | 18 +++++++-----------
2 files changed, 9 insertions(+), 20 deletions(-)
diff --git a/src/integrations/owned-refresh.ts b/src/integrations/owned-refresh.ts
index 8885f64c595..7f9e848e9a7 100644
--- a/src/integrations/owned-refresh.ts
+++ b/src/integrations/owned-refresh.ts
@@ -44,14 +44,8 @@ export interface OwnedIntegrationRefreshOutcome {
}
/**
- * Returns `null` when the client has never been connected by OpenCodex, or
- * when it was connected and has since been uninstalled. Callers use that
- * distinction to report "left alone" rather than "skipped".
- *
- * An uninstalled client keeps its ownership record: an implicit refresh must
- * not edit ownership, and a reinstall resumes refreshing. Reporting it as a
- * failure instead turned every model toggle into a "pi is not installed"
- * warning for a client the user no longer has.
+ * Returns `null` when the client has never been connected by OpenCodex.
+ * Callers use that distinction to report "left alone" rather than "skipped".
*/
export async function refreshOwnedIntegration(
input: OwnedIntegrationRefreshInput,
@@ -78,7 +72,6 @@ export async function refreshOwnedIntegration(
input.io?.now ?? Date.now,
() => refreshIntegrationCoordinated(bound, options),
);
- if (!result.ok && result.reason === "not_installed") return null;
return result.ok
? {
client: input.clientId,
diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts
index e9803a4402b..62a2f53c82e 100644
--- a/tests/clients/sync-client-integrations.test.ts
+++ b/tests/clients/sync-client-integrations.test.ts
@@ -609,12 +609,13 @@ describe("ocx sync refreshes an already-owned MCode integration", () => {
expect(store.listOperations("mcode").map(row => row.kind)).toEqual(["apply"]);
});
- test("leaves a removed MCode alone without recreating its home or reporting a failure", async () => {
+ test("does not recreate the client home or config when MCode was removed", async () => {
expect(applyIntegration(input(oldModels)).ok).toBe(true);
removeTreeWithRetry(INTEGRATION_CLIENTS.mcode.detectDir(env, home));
- expect(await refreshOwnedIntegration(input(newModels))).toBeNull();
- expect(store.readRecords().mcode?.clientId).toBe("mcode");
+ const outcome = await refreshOwnedIntegration(input(newModels));
+ expect(outcome?.ok).toBe(false);
+ expect(outcome?.reason).toContain("mcode is not installed");
expect(existsSync(INTEGRATION_CLIENTS.mcode.detectDir(env, home))).toBe(false);
expect(store.listOperations("mcode").map(row => row.kind)).toEqual(["apply"]);
});
@@ -813,14 +814,9 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => {
const detectDir = INTEGRATION_CLIENTS[clientId].detectDir(env, home);
removeTreeWithRetry(detectDir);
const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels));
- // A removed file client is left alone, not reported: a warning on every model toggle
- // for a client the user uninstalled is noise. Aside keeps its per-profile report.
- if (clientId === "pi") expect(outcomes).toEqual([]);
- else {
- expect(outcomes).toHaveLength(1);
- expect(outcomes[0]).toMatchObject({ client: clientId, ok: false });
- expect(outcomes[0]?.reason).toContain(`${clientId} is not installed`);
- }
+ expect(outcomes).toHaveLength(1);
+ expect(outcomes[0]).toMatchObject({ client: clientId, ok: false });
+ expect(outcomes[0]?.reason).toContain(`${clientId} is not installed`);
expect(existsSync(detectDir)).toBe(false);
expect(store.readRecords()[clientId]).toEqual(recordBefore);
expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]);
From 06a2580bd178e360857e2191e0c37fe008a2e014 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 06/14] Revert "fix(gui): recover Aside overwrite previews
instead of failing silently (#8384)"
This reverts commit 62bba4843cf05a972efcfaf5944bce0b5c596ed0.
---
.../content/docs/fr/guides/integrations.md | 9 +-
.../src/content/docs/guides/integrations.md | 14 +-
.../content/docs/ja/guides/integrations.md | 4 +-
.../content/docs/ko/guides/integrations.md | 4 +-
.../content/docs/ru/guides/integrations.md | 9 +-
.../content/docs/tr/guides/integrations.md | 9 +-
.../content/docs/zh-cn/guides/integrations.md | 4 +-
.../content/docs/zh-tw/guides/integrations.md | 7 +-
gui/src/i18n/de.ts | 4 -
gui/src/i18n/en.ts | 4 -
gui/src/i18n/fr.ts | 4 -
gui/src/i18n/ja.ts | 4 -
gui/src/i18n/ko.ts | 4 -
gui/src/i18n/pt.ts | 4 -
gui/src/i18n/ru.ts | 4 -
gui/src/i18n/tr.ts | 4 -
gui/src/i18n/vi.ts | 4 -
gui/src/i18n/zh-TW.ts | 4 -
gui/src/i18n/zh.ts | 4 -
.../pages/integrations/AsideProfilesPage.tsx | 47 +--
.../pages/integrations/ConsequenceDialog.tsx | 37 +-
.../integrations/FileIntegrationPage.tsx | 58 +---
.../integrations/IntegrationsOverview.tsx | 118 ++-----
.../pages/integrations/preview-recovery.ts | 21 --
.../integrations-preview-recovery.test.tsx | 315 ------------------
25 files changed, 68 insertions(+), 632 deletions(-)
delete mode 100644 gui/src/pages/integrations/preview-recovery.ts
delete mode 100644 gui/tests/integrations-preview-recovery.test.tsx
diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md
index cf0dedd48f1..f909a13a450 100644
--- a/docs-site/src/content/docs/fr/guides/integrations.md
+++ b/docs-site/src/content/docs/fr/guides/integrations.md
@@ -150,14 +150,11 @@ synchronisation, même si le document géré ne change pas.
Si le fichier change après votre examen, l'écriture est refusée car le plan est devenu obsolète. La boîte de
dialogue remplace l'ancien plan par le nouveau et vous demande de confirmer à nouveau ; elle ne relance jamais
-l'écriture automatiquement. Si l'aperçu indique qu'aucun catalogue de modèles n'est encore chargé, la boîte de
-dialogue reste ouverte et propose **Charger le catalogue et réessayer** : elle relit la liste des intégrations,
-puis redemande le plan.
+l'écriture automatiquement. Si l'aperçu est temporairement indisponible, rechargez normalement la page et
+recommencez l'action.
Aside utilise le même flux d'aperçu et de confirmation pour un seul profil sélectionné à la fois. **Synchroniser
-tous les profils** reste une action groupée distincte et n'est pas liée à un aperçu combiné unique. Un plan décrit le fichier d'un seul profil : la carte
-de la Vue d'ensemble d'une intégration Aside en conflit propose donc **Choisir un profil**, et le bloc se
-remplace depuis la page de ce profil.
+tous les profils** reste une action groupée distincte et n'est pas liée à un aperçu combiné unique.
## À quoi s'attendre, en toute transparence
diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index d8759794fac..c08ebb07992 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -481,15 +481,11 @@ though the managed document does not change.
If the file changes after you review it, the write is refused as stale. The dialog replaces the
old plan with the updated one and asks you to confirm again; it never retries the write
-automatically. When a preview reports that no model catalog is loaded yet, the dialog stays open and
-offers **Load catalog and retry**: it reads the integration list again — the ordinary load that
-retains the catalog, never a preview that gathers models — and then asks for the plan once more.
-
-Aside uses the same preview and confirmation flow for one selected profile at a time, and a plan
-describes exactly that profile's file. Because one plan cannot describe several independently
-changing files, the Overview card for a conflicted Aside integration opens **Connect → Aside** with
-**Choose profile** rather than a Replace action; replace the block on the profile's own page. **Sync
-all profiles** remains a separate bulk action and is not bound to one combined preview.
+automatically. If a preview is temporarily unavailable, reload the page normally and start the
+action again.
+
+Aside uses the same preview and confirmation flow for one selected profile at a time. **Sync all
+profiles** remains a separate bulk action and is not bound to one combined preview.
## What to expect, honestly
diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md
index 22dab73354b..e34ff7db555 100644
--- a/docs-site/src/content/docs/ja/guides/integrations.md
+++ b/docs-site/src/content/docs/ja/guides/integrations.md
@@ -100,9 +100,9 @@ Apply、Replace、Disable、Restore はプレビューから始まります。
計画に変更がない場合、管理対象のクライアント文書は要求した状態にすでになっています。選択された Aside プロファイルでは、文書に変更がなくても、確定によって同期の設定が保存される場合があります。
-確認後にファイルが変更されると、古い計画として書き込みが拒否されます。ダイアログは更新された計画に置き換えて再確認を求め、自動再試行はしません。プレビューがモデルカタログ未読み込みを報告した場合、ダイアログは開いたままで **カタログを読み込んで再試行** を表示します。統合一覧を読み直してから計画を再度取得します。
+確認後にファイルが変更されると、古い計画として書き込みが拒否されます。ダイアログは更新された計画に置き換えて再確認を求め、自動再試行はしません。プレビューが一時的に使えない場合は、通常どおりページを再読み込みして操作をやり直してください。
-Aside も選択したプロファイル 1 件ずつ同じプレビューと確認の手順を使います。**Sync all profiles** は別の一括操作であり、1 つの統合プレビューには結び付きません。1 つの計画は 1 プロファイルのファイルだけを表すため、競合している Aside の概要カードには **プロファイルを選択** が表示され、ブロックの置き換えはそのプロファイルのページで行います。
+Aside も選択したプロファイル 1 件ずつ同じプレビューと確認の手順を使います。**Sync all profiles** は別の一括操作であり、1 つの統合プレビューには結び付きません。
## 実際に起こること
diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md
index 58d8a14e361..fd544bb661c 100644
--- a/docs-site/src/content/docs/ko/guides/integrations.md
+++ b/docs-site/src/content/docs/ko/guides/integrations.md
@@ -108,9 +108,9 @@ Apply, Replace, Disable, Restore는 미리 보기로 시작합니다. 대화 상
계획에 변경이 없다면 관리 대상 클라이언트 문서가 이미 요청한 상태라는 뜻입니다. Aside 프로필을 선택했다면 관리 문서가 바뀌지 않아도 확인 시 그 프로필의 동기화 기본 설정을 저장할 수 있습니다.
-검토 후 파일이 바뀌면 오래된 계획으로 판단해 쓰기를 거부합니다. 대화 상자가 갱신된 계획을 보여주고 다시 확인을 요청하며, 자동으로 쓰기를 재시도하지 않습니다. 미리 보기가 모델 카탈로그가 아직 로드되지 않았다고 알리면 대화 상자는 열린 채로 **카탈로그 로드 후 다시 시도**를 제공합니다. 통합 목록을 다시 읽은 뒤 계획을 다시 요청합니다.
+검토 후 파일이 바뀌면 오래된 계획으로 판단해 쓰기를 거부합니다. 대화 상자가 갱신된 계획을 보여주고 다시 확인을 요청하며, 자동으로 쓰기를 재시도하지 않습니다. 미리 보기를 일시적으로 사용할 수 없다면 페이지를 일반적으로 새로고침하고 작업을 다시 시작하세요.
-Aside는 선택한 프로필 하나에 같은 미리 보기와 확인 흐름을 사용합니다. **Sync all profiles**는 별도 일괄 작업이며 하나의 통합 미리 보기에 묶이지 않습니다. 하나의 계획은 한 프로필의 파일만 설명하므로, 충돌 상태인 Aside 개요 카드는 **프로필 선택**을 제공하고 블록 교체는 해당 프로필 페이지에서 합니다.
+Aside는 선택한 프로필 하나에 같은 미리 보기와 확인 흐름을 사용합니다. **Sync all profiles**는 별도 일괄 작업이며 하나의 통합 미리 보기에 묶이지 않습니다.
## 실제로 예상해야 할 동작
diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md
index f55330c7478..6afaa08e162 100644
--- a/docs-site/src/content/docs/ru/guides/integrations.md
+++ b/docs-site/src/content/docs/ru/guides/integrations.md
@@ -272,15 +272,12 @@ Apply, Replace, Disable и Restore теперь начинаются с пред
Если файл изменится после просмотра, запись будет отклонена как устаревшая.
Диалог заменит старый план новым и попросит подтвердить ещё раз; автоматического
-повтора записи не будет. Если предпросмотр сообщает, что каталог моделей ещё
-не загружен, диалог остаётся открытым и предлагает **Загрузить каталог и повторить**:
-он заново читает список интеграций и снова запрашивает план.
+повтора записи не будет. Если предпросмотр временно недоступен, обычным способом
+обновите страницу и начните действие заново.
Для отдельного профиля Aside применяется тот же сценарий предпросмотра и
подтверждения. **Sync all profiles** остаётся отдельным массовым действием
-без одного общего предпросмотра. План описывает файл одного профиля, поэтому карточка
-обзора для Aside в состоянии конфликта предлагает **Выбрать профиль**, а блок
-заменяется на странице этого профиля.
+без одного общего предпросмотра.
## Чего ожидать на практике
diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md
index ba9d2b916c7..460e359b560 100644
--- a/docs-site/src/content/docs/tr/guides/integrations.md
+++ b/docs-site/src/content/docs/tr/guides/integrations.md
@@ -181,14 +181,11 @@ bir Aside profilinde onaylama, yönetilen belge değişmese bile profilin eşitl
Dosya siz inceledikten sonra değişirse yazma işlemi eski plan nedeniyle reddedilir. İletişim kutusu
eski planı güncel planla değiştirir ve yeniden açıkça onaylamanızı ister; yazma işlemini hiçbir zaman
-otomatik olarak yeniden denemez. Önizleme henüz model kataloğu yüklenmediğini bildirirse
-iletişim kutusu açık kalır ve **Kataloğu yükle ve yeniden dene** seçeneğini sunar: entegrasyon listesini
-yeniden okur ve planı tekrar ister.
+otomatik olarak yeniden denemez. Önizleme geçici olarak kullanılamıyorsa sayfayı normal şekilde
+yenileyin ve işlemi yeniden başlatın.
Aside, aynı önizleme ve onay akışını bir defada seçili tek bir profil için kullanır. **Tüm profilleri
-eşitle** ayrı bir toplu işlem olarak kalır ve tek bir birleşik önizlemeye bağlı değildir. Bir plan yalnızca bir profilin dosyasını
-anlatır; bu yüzden çakışmadaki Aside genel bakış kartı **Profil seç** sunar ve blok o profilin
-sayfasında değiştirilir.
+eşitle** ayrı bir toplu işlem olarak kalır ve tek bir birleşik önizlemeye bağlı değildir.
## Dürüstçe ne beklenmeli?
diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md
index 46d0cfd5cbb..6b8c37a9f81 100644
--- a/docs-site/src/content/docs/zh-cn/guides/integrations.md
+++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md
@@ -100,9 +100,9 @@ Apply、Replace、Disable 和 Restore 都先显示预览。对话框准确列出
如果计划显示没有变更,说明托管客户端文档已处于请求的状态。对于选中的 Aside 配置文件,即使托管文档不变,确认操作仍可能保存该配置文件的同步偏好。
-如果检查后文件发生变化,写入会因计划过期而被拒绝。对话框会用更新后的计划替换旧计划,并要求再次确认;绝不会自动重试写入。如果预览提示尚未加载模型目录,对话框会保持打开并提供 **加载目录并重试**:它会重新读取集成列表,然后再次请求计划。
+如果检查后文件发生变化,写入会因计划过期而被拒绝。对话框会用更新后的计划替换旧计划,并要求再次确认;绝不会自动重试写入。如果预览暂时不可用,请正常重新加载页面,再发起操作。
-Aside 对单个选中配置文件采用相同的预览和确认流程。**Sync all profiles** 仍是单独的批量操作,不与一份合并预览绑定。一份计划只描述一个配置文件的文件,因此处于冲突状态的 Aside 概览卡片会提供 **选择配置文件**,请在该配置文件页面替换配置块。
+Aside 对单个选中配置文件采用相同的预览和确认流程。**Sync all profiles** 仍是单独的批量操作,不与一份合并预览绑定。
## 实际效果与限制
diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md
index 30eb97ce21d..e4b070287aa 100644
--- a/docs-site/src/content/docs/zh-tw/guides/integrations.md
+++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md
@@ -104,12 +104,11 @@ Hermes 的會話標識升級是上述衝突規則的特例:既有受管設定
即使受管理的文件沒有變更,確認後仍可能儲存該設定檔的同步偏好。
如果檔案在你檢視後又有變更,寫入會因計畫過期而被拒絕。對話框會用更新後的計畫取代舊
-計畫,並要求你再次明確確認;它絕不會自動重試寫入。如果預覽表示尚未載入模型目錄,對話框會保持開啟並
-提供 **載入目錄並重試**:它會重新讀取整合清單,再重新要求計畫。
+計畫,並要求你再次明確確認;它絕不會自動重試寫入。如果預覽暫時無法使用,請正常重新
+載入頁面,再重新開始該操作。
Aside 會對一次選取的單一設定檔使用相同的預覽與確認流程。**同步所有設定檔**仍是獨立的
-批次操作,不會綁定到單一合併預覽。一份計畫只描述一個設定檔的檔案,
-因此處於衝突狀態的 Aside 概覽卡片會提供 **選擇設定檔**,請在該設定檔頁面取代設定區塊。
+批次操作,不會綁定到單一合併預覽。
## 誠實的預期
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 2c8b21ff32c..78c029bba4f 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1986,10 +1986,6 @@ export const de: Record = {
"integrations.preview.loading": "Der genaue Änderungsplan wird geladen…",
"integrations.preview.failed": "Der Änderungsplan konnte nicht geladen werden. Es wurde nichts geändert.",
"integrations.preview.stale": "Die Integration wurde während der Bestätigung geändert. Prüfe den aktualisierten Plan und bestätige erneut.",
- "integrations.preview.catalogUnavailable": "Der Modellkatalog ist noch nicht geladen, daher kann diese Änderung nicht geplant werden. Lade ihn, um fortzufahren.",
- "integrations.preview.loadCatalogRetry": "Katalog laden und erneut versuchen",
- "integrations.preview.recovering": "Katalog wird geladen…",
- "integrations.action.chooseProfile": "Profil wählen",
"integrations.dialog.apply.title": "Diese Integration anwenden?",
"integrations.dialog.apply.changes": "opencodex fügt der Client-Konfiguration seine verwalteten Anbieter-Einstellungen hinzu.",
"integrations.dialog.apply.breakage": "Der Client verwendet die Modelle, die diese opencodex-Instanz derzeit bereitstellt.",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 31adb533d58..6fa11a26f50 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -2624,10 +2624,6 @@ export const en = {
"integrations.preview.loading": "Loading the exact change plan…",
"integrations.preview.failed": "The change plan could not be loaded. Nothing was changed.",
"integrations.preview.stale": "The integration changed while you were confirming. Review the updated plan and confirm again.",
- "integrations.preview.catalogUnavailable": "The model catalog is not loaded yet, so this change cannot be planned. Load it to continue.",
- "integrations.preview.loadCatalogRetry": "Load catalog and retry",
- "integrations.preview.recovering": "Loading catalog…",
- "integrations.action.chooseProfile": "Choose profile",
"integrations.dialog.apply.title": "Apply this integration?",
"integrations.dialog.apply.changes": "opencodex will add its managed provider settings to the client configuration.",
"integrations.dialog.apply.breakage": "The client will use the models currently exposed by this opencodex instance.",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index ae9377ed0fa..37191a7b98f 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -2551,10 +2551,6 @@ export const fr: Record = {
"integrations.preview.loading": "Chargement du plan de modification exact…",
"integrations.preview.failed": "Le plan de modification n’a pas pu être chargé. Rien n’a été modifié.",
"integrations.preview.stale": "L’intégration a changé pendant la confirmation. Vérifiez le plan mis à jour et confirmez à nouveau.",
- "integrations.preview.catalogUnavailable": "Le catalogue de modèles n’est pas encore chargé, cette modification ne peut donc pas être planifiée. Chargez-le pour continuer.",
- "integrations.preview.loadCatalogRetry": "Charger le catalogue et réessayer",
- "integrations.preview.recovering": "Chargement du catalogue…",
- "integrations.action.chooseProfile": "Choisir un profil",
"integrations.dialog.apply.title": "Appliquer cette intégration ?",
"integrations.dialog.apply.changes": "opencodex ajoutera ses paramètres de fournisseur gérés à la configuration du client.",
"integrations.dialog.apply.breakage": "Le client utilisera les modèles actuellement exposés par cette instance opencodex.",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 18f5759195a..5f1be4e8afd 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -2446,10 +2446,6 @@ export const ja: Record = {
"integrations.preview.loading": "正確な変更プランを読み込んでいます…",
"integrations.preview.failed": "変更プランを読み込めませんでした。変更は行われていません。",
"integrations.preview.stale": "確認中に統合の状態が変わりました。更新されたプランを確認して、もう一度確定してください。",
- "integrations.preview.catalogUnavailable": "モデルカタログがまだ読み込まれていないため、この変更を計画できません。読み込んでから続行してください。",
- "integrations.preview.loadCatalogRetry": "カタログを読み込んで再試行",
- "integrations.preview.recovering": "カタログを読み込み中…",
- "integrations.action.chooseProfile": "プロファイルを選択",
"integrations.dialog.apply.title": "この統合を適用しますか?",
"integrations.dialog.apply.changes": "opencodex が管理するプロバイダー設定をクライアント設定に追加します。",
"integrations.dialog.apply.breakage": "クライアントは、この opencodex インスタンスが現在公開しているモデルを使用します。",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 5e3251753fc..bc118190596 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -2030,10 +2030,6 @@ export const ko: Record = {
"integrations.preview.loading": "정확한 변경 계획을 불러오는 중…",
"integrations.preview.failed": "변경 계획을 불러오지 못했습니다. 아무것도 변경하지 않았습니다.",
"integrations.preview.stale": "확인하는 동안 통합 상태가 바뀌었습니다. 갱신된 계획을 검토하고 다시 확인하세요.",
- "integrations.preview.catalogUnavailable": "모델 카탈로그가 아직 로드되지 않아 이 변경을 계획할 수 없습니다. 계속하려면 카탈로그를 로드하세요.",
- "integrations.preview.loadCatalogRetry": "카탈로그 로드 후 다시 시도",
- "integrations.preview.recovering": "카탈로그 로드 중…",
- "integrations.action.chooseProfile": "프로필 선택",
"integrations.dialog.apply.title": "이 통합을 적용할까요?",
"integrations.dialog.apply.changes": "opencodex가 관리하는 공급자 설정을 클라이언트 구성에 추가합니다.",
"integrations.dialog.apply.breakage": "클라이언트는 현재 이 opencodex 인스턴스가 제공하는 모델을 사용합니다.",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index 9630d5f8638..4657ba62fab 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -2594,10 +2594,6 @@ export const pt: Record = {
"integrations.preview.loading": "Carregando o plano exato de alterações…",
"integrations.preview.failed": "Não foi possível carregar o plano de alterações. Nada foi alterado.",
"integrations.preview.stale": "A integração mudou enquanto você confirmava. Revise o plano atualizado e confirme novamente.",
- "integrations.preview.catalogUnavailable": "O catálogo de modelos ainda não foi carregado, então esta alteração não pode ser planejada. Carregue-o para continuar.",
- "integrations.preview.loadCatalogRetry": "Carregar catálogo e tentar novamente",
- "integrations.preview.recovering": "Carregando catálogo…",
- "integrations.action.chooseProfile": "Escolher perfil",
"integrations.dialog.apply.title": "Aplicar esta integração?",
"integrations.dialog.apply.changes": "O opencodex vai adicionar suas configurações de provedor gerenciadas à configuração do cliente.",
"integrations.dialog.apply.breakage": "O cliente passará a usar os modelos expostos atualmente por esta instância do opencodex.",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 775326e2047..ae0973335a0 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -2537,10 +2537,6 @@ export const ru: Record = {
"integrations.preview.loading": "Загрузка точного плана изменений…",
"integrations.preview.failed": "Не удалось загрузить план изменений. Ничего не изменено.",
"integrations.preview.stale": "Интеграция изменилась во время подтверждения. Проверьте обновлённый план и подтвердите снова.",
- "integrations.preview.catalogUnavailable": "Каталог моделей ещё не загружен, поэтому это изменение нельзя спланировать. Загрузите его, чтобы продолжить.",
- "integrations.preview.loadCatalogRetry": "Загрузить каталог и повторить",
- "integrations.preview.recovering": "Загрузка каталога…",
- "integrations.action.chooseProfile": "Выбрать профиль",
"integrations.dialog.apply.title": "Применить эту интеграцию?",
"integrations.dialog.apply.changes": "opencodex добавит управляемые настройки провайдера в конфигурацию клиента.",
"integrations.dialog.apply.breakage": "Клиент будет использовать модели, доступные через этот экземпляр opencodex.",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index b6d5382de8a..5d4afa1c925 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -2564,10 +2564,6 @@ export const tr: Record = {
"integrations.preview.loading": "Kesin değişiklik planı yükleniyor…",
"integrations.preview.failed": "Değişiklik planı yüklenemedi. Hiçbir şey değiştirilmedi.",
"integrations.preview.stale": "Onay sırasında entegrasyon değişti. Güncellenen planı inceleyip yeniden onaylayın.",
- "integrations.preview.catalogUnavailable": "Model kataloğu henüz yüklenmediği için bu değişiklik planlanamıyor. Devam etmek için kataloğu yükleyin.",
- "integrations.preview.loadCatalogRetry": "Kataloğu yükle ve yeniden dene",
- "integrations.preview.recovering": "Katalog yükleniyor…",
- "integrations.action.chooseProfile": "Profil seç",
"integrations.dialog.apply.title": "Bu entegrasyon uygulansın mı?",
"integrations.dialog.apply.changes": "opencodex, yönettiği sağlayıcı ayarlarını istemci yapılandırmasına ekleyecek.",
"integrations.dialog.apply.breakage": "İstemci, bu opencodex örneğinin sunduğu modelleri kullanacak.",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index bdc68e37c63..a3d4a07f8be 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -2572,10 +2572,6 @@ export const vi: Record = {
"integrations.preview.loading": "Đang tải kế hoạch thay đổi chính xác…",
"integrations.preview.failed": "Không thể tải kế hoạch thay đổi. Không có gì bị thay đổi.",
"integrations.preview.stale": "Tích hợp đã thay đổi trong lúc xác nhận. Hãy xem kế hoạch mới và xác nhận lại.",
- "integrations.preview.catalogUnavailable": "Danh mục mô hình chưa được tải nên không thể lập kế hoạch cho thay đổi này. Hãy tải danh mục để tiếp tục.",
- "integrations.preview.loadCatalogRetry": "Tải danh mục và thử lại",
- "integrations.preview.recovering": "Đang tải danh mục…",
- "integrations.action.chooseProfile": "Chọn hồ sơ",
"integrations.dialog.apply.title": "Áp dụng tích hợp này?",
"integrations.dialog.apply.changes": "opencodex sẽ thêm các cài đặt nhà cung cấp do nó quản lý vào cấu hình máy khách.",
"integrations.dialog.apply.breakage": "Máy khách sẽ dùng các mô hình hiện được phiên bản opencodex này cung cấp.",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index 4e6f1d230ce..dad97b42417 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -3506,10 +3506,6 @@ export const zhTW: Record = {
"integrations.preview.loading": "正在載入精確的變更計畫…",
"integrations.preview.failed": "無法載入變更計畫。未進行任何變更。",
"integrations.preview.stale": "確認期間整合狀態已變更。請檢視更新後的計畫並再次確認。",
- "integrations.preview.catalogUnavailable": "模型目錄尚未載入,無法規劃此變更。請載入後繼續。",
- "integrations.preview.loadCatalogRetry": "載入目錄並重試",
- "integrations.preview.recovering": "正在載入目錄…",
- "integrations.action.chooseProfile": "選擇設定檔",
"integrations.dialog.apply.title": "套用此整合?",
"integrations.dialog.apply.changes": "opencodex 會將其管理的供應商設定加入用戶端設定。",
"integrations.dialog.apply.breakage": "用戶端將使用此 opencodex 執行個體目前提供的模型。",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 565b169bba7..b85962505fe 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -2003,10 +2003,6 @@ export const zh: Record = {
"integrations.preview.loading": "正在加载准确的更改计划…",
"integrations.preview.failed": "无法加载更改计划。未进行任何更改。",
"integrations.preview.stale": "确认期间集成状态已变化。请查看更新后的计划并再次确认。",
- "integrations.preview.catalogUnavailable": "模型目录尚未加载,无法规划此更改。请加载后继续。",
- "integrations.preview.loadCatalogRetry": "加载目录并重试",
- "integrations.preview.recovering": "正在加载目录…",
- "integrations.action.chooseProfile": "选择配置文件",
"integrations.dialog.apply.title": "应用此集成?",
"integrations.dialog.apply.changes": "opencodex 会将其管理的提供商设置添加到客户端配置。",
"integrations.dialog.apply.breakage": "客户端将使用此 opencodex 实例当前提供的模型。",
diff --git a/gui/src/pages/integrations/AsideProfilesPage.tsx b/gui/src/pages/integrations/AsideProfilesPage.tsx
index 7a19d039d3f..c7981995379 100644
--- a/gui/src/pages/integrations/AsideProfilesPage.tsx
+++ b/gui/src/pages/integrations/AsideProfilesPage.tsx
@@ -10,12 +10,11 @@ import FileIntegrationPage from "./FileIntegrationPage";
import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog";
import { describeRefusal, describeAsideProfileOutcome } from "./refusal-copy";
import type { AsideProfileOutcome } from "./aside-profile-contract";
-import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery";
import {
bindingFor,
IntegrationApiError,
+ isIntegrationPreviewUnavailable,
previewIntegrationMutation,
- loadIntegrationState,
toggleIntegration,
type IntegrationMutationPlan,
} from "./integration-api";
@@ -45,7 +44,6 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase:
plan: IntegrationMutationPlan | null;
loading: boolean;
failure: string | null;
- recovery: PreviewRecovery | null;
} | null>(null);
const previewAbortRef = useRef(null);
const previewGenerationRef = useRef(0);
@@ -106,50 +104,29 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase:
setPending(null);
}
};
- const requestProfileMutation = async (
- profile: AsideProfileStatus,
- enabled: boolean,
- /** Recovery replaces the failed plan it is retrying; the first request never does. */
- replacing = false,
- ) => {
- if (pendingRef.current || (planned && !replacing)) return;
+ const requestProfileMutation = async (profile: AsideProfileStatus, enabled: boolean) => {
+ if (pendingRef.current || planned) return;
const controller = new AbortController();
const generation = previewGenerationRef.current + 1;
previewGenerationRef.current = generation;
previewAbortRef.current?.abort();
previewAbortRef.current = controller;
- setPlanned({ profile, enabled, plan: null, loading: true, failure: null, recovery: null });
+ setPlanned({ profile, enabled, plan: null, loading: true, failure: null });
try {
const plan = await previewIntegrationMutation(apiBase, "aside", enabled ? "apply" : "disable", controller.signal, profile.profileId);
if (controller.signal.aborted || generation !== previewGenerationRef.current) return;
previewAbortRef.current = null;
- setPlanned({ profile, enabled, plan, loading: false, failure: null, recovery: null });
+ setPlanned({ profile, enabled, plan, loading: false, failure: null });
} catch (error) {
if (controller.signal.aborted || generation !== previewGenerationRef.current) return;
previewAbortRef.current = null;
- // A missing retained roster is recoverable: keep the dialog and offer the
- // ordinary profile read that fills it, so the retry can produce a plan.
- const recovery = previewRecoveryFor(error);
- setPlanned({
- profile, enabled, plan: null, loading: false, recovery,
- failure: recovery === null ? t("integrations.preview.failed") : null,
- });
- }
- };
- const recoverPlanned = async () => {
- const pendingPlan = planned;
- if (!pendingPlan) return;
- // Close and any newer preview move the generation; a stale recovery must not revive it.
- const generation = previewGenerationRef.current;
- try {
- await loadIntegrationState(apiBase, "aside", undefined, pendingPlan.profile.profileId);
- } catch {
- // Fall through: the preview answers again, and a second banner here
- // would only hide the refusal the user can act on.
+ if (isIntegrationPreviewUnavailable(error)) {
+ setPlanned(null);
+ resource.refresh();
+ return;
+ }
+ setPlanned({ profile, enabled, plan: null, loading: false, failure: t("integrations.preview.failed") });
}
- if (generation !== previewGenerationRef.current) return;
- resource.refresh();
- await requestProfileMutation(pendingPlan.profile, pendingPlan.enabled, true);
};
const closePlanned = () => {
previewGenerationRef.current += 1;
@@ -258,8 +235,6 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase:
plan={planned.plan}
planLoading={planned.loading}
planFailure={planned.failure}
- previewRecovery={planned.recovery}
- onPreviewRecovery={recoverPlanned}
onClose={closePlanned}
onConfirm={async plan => {
if (!plan) return;
diff --git a/gui/src/pages/integrations/ConsequenceDialog.tsx b/gui/src/pages/integrations/ConsequenceDialog.tsx
index 80acb01b99b..94b964ee2f2 100644
--- a/gui/src/pages/integrations/ConsequenceDialog.tsx
+++ b/gui/src/pages/integrations/ConsequenceDialog.tsx
@@ -3,7 +3,6 @@ import { useT, type TKey } from "../../i18n/shared";
import { Notice } from "../../ui";
import IntegrationPlanDetails, { type LabeledIntegrationPlan } from "./IntegrationPlanDetails";
import { IntegrationApiError, type IntegrationMutationPlan } from "./integration-api";
-import type { PreviewRecovery } from "./preview-recovery";
export interface ConsequenceCopy {
titleKey: TKey;
@@ -36,8 +35,6 @@ export default function ConsequenceDialog({
planStale = false,
planLoading = false,
planFailure = null,
- previewRecovery = null,
- onPreviewRecovery,
missingStorePath,
onConfirm,
onClose,
@@ -49,13 +46,6 @@ export default function ConsequenceDialog({
planStale?: boolean;
planLoading?: boolean;
planFailure?: string | null;
- /**
- * A preview that could not be made for a reason the user can clear by reading
- * the page's data again (no retained model roster). The dialog then shows the
- * reason and one explicit button instead of a failure the user can only stare at.
- */
- previewRecovery?: PreviewRecovery | null;
- onPreviewRecovery?: () => Promise | void;
/** Passed through to the plan details; see IntegrationPlanDetails. */
missingStorePath?: string;
onConfirm: (plan?: IntegrationMutationPlan) => Promise | void;
@@ -67,7 +57,6 @@ export default function ConsequenceDialog({
const closeRef = useRef(null);
const [pending, setPending] = useState(false);
const [failure, setFailure] = useState(null);
- const [recovering, setRecovering] = useState(false);
const [staleOverride, setStaleOverride] = useState<{
sourceFingerprint: string | null;
plan: IntegrationMutationPlan;
@@ -126,19 +115,6 @@ export default function ConsequenceDialog({
}
}, [activePlan, onConfirm, pending, plan?.fingerprint, t]);
- // Recovery is its own press, and its own busy state: it must not look like a
- // confirmation, and it must not leave the confirm button enabled while the
- // plan it would confirm is still missing.
- const recoverPreview = useCallback(async () => {
- if (!onPreviewRecovery || recovering) return;
- setRecovering(true);
- try {
- await onPreviewRecovery();
- } finally {
- setRecovering(false);
- }
- }, [onPreviewRecovery, recovering]);
-
const slots: ReactNode[] = [
,
,
@@ -175,23 +151,12 @@ export default function ConsequenceDialog({
{planLoading &&
- {previewRecovery !== null && onPreviewRecovery && (
-
- )}
- {/*
- A refused preview leaves no plan to confirm. Without `previewRecovery`
- here the button looked live and did nothing, which reads as a broken
- dialog rather than the one retry the user can actually take.
- */}
-
diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx
index fa42fa0866f..51f78320e64 100644
--- a/gui/src/pages/integrations/FileIntegrationPage.tsx
+++ b/gui/src/pages/integrations/FileIntegrationPage.tsx
@@ -12,7 +12,6 @@ import RaycastPlanNotice from "./RaycastPlanNotice";
import DroidReasoningDefaultsPanel from "./DroidReasoningDefaultsPanel";
import { RollbackHistory } from "./RollbackHistory";
import { describeRefusal } from "./refusal-copy";
-import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery";
import {
loadIntegrationJournal,
loadIntegrationState,
@@ -22,6 +21,7 @@ import {
bindingFor,
IntegrationApiError,
deleteJournalEntry,
+ isIntegrationPreviewUnavailable,
isMissingJournalEntry,
type FileIntegrationClientId,
type IntegrationJournalRow,
@@ -165,7 +165,6 @@ function FileIntegrationControls({
plan: IntegrationMutationPlan | null;
loading: boolean;
failure: string | null;
- recovery: PreviewRecovery | null;
droidReasoningDefaults?: Record;
} | null>(null);
const previewAbortRef = useRef(null);
@@ -230,67 +229,32 @@ function FileIntegrationControls({
? droidReasoningDraft.values
: undefined)
: undefined,
- /** Recovery replaces the failed plan it is retrying; the first request never does. */
- replacing = false,
) => {
- if (!status || pending || (plannedMutation && !replacing)) return;
+ if (!status || pending || plannedMutation) return;
const defaultsSnapshot = reasoningDefaults === undefined ? undefined : { ...reasoningDefaults };
const controller = new AbortController();
const generation = previewGenerationRef.current + 1;
previewGenerationRef.current = generation;
previewAbortRef.current?.abort();
previewAbortRef.current = controller;
- setPlannedMutation({ operation, plan: null, loading: true, failure: null, recovery: null });
+ setPlannedMutation({ operation, plan: null, loading: true, failure: null });
try {
const plan = await previewIntegrationMutation(apiBase, client, operation, controller.signal, profileId, defaultsSnapshot, hermesProfile);
if (controller.signal.aborted || generation !== previewGenerationRef.current) return;
previewAbortRef.current = null;
- setPlannedMutation({ operation, plan, loading: false, failure: null, recovery: null, droidReasoningDefaults: defaultsSnapshot });
+ setPlannedMutation({ operation, plan, loading: false, failure: null, droidReasoningDefaults: defaultsSnapshot });
} catch (error) {
if (controller.signal.aborted || generation !== previewGenerationRef.current) return;
previewAbortRef.current = null;
- // A missing retained roster is recoverable from this dialog. Closing it,
- // as this used to, left the user with a button that appeared to do
- // nothing at all; the preview itself never gathers models.
- const recovery = previewRecoveryFor(error);
- setPlannedMutation({
- operation, plan: null, loading: false, recovery,
- failure: recovery === null ? t("integrations.preview.failed") : null,
- });
+ if (isIntegrationPreviewUnavailable(error)) {
+ setPlannedMutation(null);
+ refresh();
+ return;
+ }
+ setPlannedMutation({ operation, plan: null, loading: false, failure: t("integrations.preview.failed") });
}
};
- /*
- * Re-read this surface's own data, then ask for the plan again.
- *
- * The state resource refreshes through the ordinary profile/client read, and the
- * server's integration reads are the loads that retain an export roster. No new
- * endpoint and no write: the preview stays a read, and the retry only repeats
- * the question now that an answer exists.
- */
- const recoverPlannedMutation = async () => {
- const pendingOperation = plannedMutation;
- if (!pendingOperation) return;
- // Close and any newer preview move the generation; a stale recovery must not revive it.
- const generation = previewGenerationRef.current;
- const defaults = pendingOperation.droidReasoningDefaults;
- /*
- * This read IS the recovery: the server retains its export roster from an
- * ordinary integration load, so waiting for this one is what makes the
- * next preview answerable. The resource refresh below only repaints the
- * surface with the same data; it cannot be awaited for the answer.
- */
- try {
- await loadIntegrationState(apiBase, client, undefined, profileId, hermesProfile);
- } catch {
- // A failed read is not a plan; fall through and let the preview say so
- // again rather than reporting a second, unrelated error.
- }
- if (generation !== previewGenerationRef.current) return;
- refresh();
- await requestMutation(pendingOperation.operation, defaults, true);
- };
-
const closePlannedMutation = () => {
previewGenerationRef.current += 1;
previewAbortRef.current?.abort();
@@ -539,8 +503,6 @@ function FileIntegrationControls({
plan={plannedMutation.plan}
planLoading={plannedMutation.loading}
planFailure={plannedMutation.failure}
- previewRecovery={plannedMutation.recovery}
- onPreviewRecovery={recoverPlannedMutation}
missingStorePath={status.supersededReason === "missing-store" ? status.supersededBy : undefined}
onClose={closePlannedMutation}
onConfirm={async plan => { if (plan) await mutate(plan); }}
diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx
index 88ac713e035..ee8c136ace4 100644
--- a/gui/src/pages/integrations/IntegrationsOverview.tsx
+++ b/gui/src/pages/integrations/IntegrationsOverview.tsx
@@ -12,7 +12,6 @@ import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog";
import RestoreDialog from "./RestoreDialog";
import { RollbackHistory } from "./RollbackHistory";
import { describeRefusal } from "./refusal-copy";
-import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery";
import { loadCursorIntegrationStatus } from "./cursor-api";
import {
buildOverviewRows,
@@ -35,6 +34,7 @@ import {
bindingFor,
deleteJournalEntry,
IntegrationApiError,
+ isIntegrationPreviewUnavailable,
isMissingJournalEntry,
type IntegrationJournalRow,
type IntegrationStatus,
@@ -110,7 +110,6 @@ interface BulkDisableState {
actions: BulkDisableAction[];
loading: boolean;
failure: string | null;
- recovery: PreviewRecovery | null;
stale: boolean;
failures: string[];
}
@@ -145,7 +144,6 @@ function OverviewCard({
onOpen,
onToggle,
onOverwrite,
- onChooseProfile,
}: {
row: OverviewRow;
pending: boolean;
@@ -154,8 +152,6 @@ function OverviewCard({
onToggle: (() => void) | null;
/** Present only for a conflicted file client; null everywhere else. */
onOverwrite: (() => void) | null;
- /** Aside only: one plan names one profile, so the aggregate card cannot replace. */
- onChooseProfile: (() => void) | null;
}) {
const t = useT();
const detail = row.detail ?? (row.detailKey ? t(row.detailKey, row.detailVars ?? undefined) : null);
@@ -239,17 +235,6 @@ function OverviewCard({
{t("integrations.action.overwrite")}
)}
- {/*
- Aside has no aggregate writer: a fingerprint describes one profile's
- file, and the server refuses an unprofiled plan for exactly that
- reason. The card therefore sends the user to the per-profile surface
- instead of opening a dialog that could never load a plan.
- */}
- {onChooseProfile && (
-
- {t("integrations.action.chooseProfile")}
-
- )}
);
@@ -288,7 +273,6 @@ export default function IntegrationsOverview({
plan: IntegrationMutationPlan | null;
loading: boolean;
failure: string | null;
- recovery: PreviewRecovery | null;
} | null>(null);
const [bulkPlans, setBulkPlans] = useState(null);
const cardPreviewAbortRef = useRef(null);
@@ -477,17 +461,14 @@ export default function IntegrationsOverview({
* single-client PUT the card uses, so every client gets its own snapshot and
* its own journal row, and one refusal cannot silently swallow the rest.
*/
- const requestDisableAll = async (
- /** Recovery replaces the failed plan set it is retrying; the first request never does. */
- replacing = false,
- ) => {
- if (bulkPending || (bulkPlans && !replacing) || appliedClients.length === 0) return;
+ const requestDisableAll = async () => {
+ if (bulkPending || bulkPlans || appliedClients.length === 0) return;
const controller = new AbortController();
const generation = bulkPreviewGenerationRef.current + 1;
bulkPreviewGenerationRef.current = generation;
bulkPreviewAbortRef.current?.abort();
bulkPreviewAbortRef.current = controller;
- setBulkPlans({ actions: [], loading: true, failure: null, recovery: null, stale: false, failures: [] });
+ setBulkPlans({ actions: [], loading: true, failure: null, stale: false, failures: [] });
try {
const actions = await Promise.all(appliedClients.map(async client => {
const row = rows.find(candidate => candidate.status?.clientId === client.clientId);
@@ -499,30 +480,17 @@ export default function IntegrationsOverview({
}));
if (controller.signal.aborted || generation !== bulkPreviewGenerationRef.current) return;
bulkPreviewAbortRef.current = null;
- setBulkPlans({ actions, loading: false, failure: null, recovery: null, stale: false, failures: [] });
+ setBulkPlans({ actions, loading: false, failure: null, stale: false, failures: [] });
} catch (error) {
if (controller.signal.aborted || generation !== bulkPreviewGenerationRef.current) return;
bulkPreviewAbortRef.current = null;
- const recovery = previewRecoveryFor(error);
- setBulkPlans({
- actions: [], loading: false, recovery, stale: false, failures: [],
- failure: recovery === null ? t("integrations.preview.failed") : null,
- });
- }
- };
-
- const recoverBulkPlans = async () => {
- // Close and any newer bulk preview move the generation; a stale recovery must not revive it.
- const generation = bulkPreviewGenerationRef.current;
- try {
- await loadIntegrationStates(apiBase);
- } catch {
- // Fall through: the next preview answers again, and its refusal is the
- // message the user can act on.
+ if (isIntegrationPreviewUnavailable(error)) {
+ setBulkPlans(null);
+ refresh();
+ return;
+ }
+ setBulkPlans({ actions: [], loading: false, failure: t("integrations.preview.failed"), stale: false, failures: [] });
}
- if (generation !== bulkPreviewGenerationRef.current) return;
- refresh();
- await requestDisableAll(true);
};
const closeBulkPlans = () => {
@@ -579,7 +547,7 @@ export default function IntegrationsOverview({
if (staleActions.length > 0) {
refresh();
setBulkPending(false);
- setBulkPlans({ actions: staleActions, loading: false, failure: null, recovery: null, stale: true, failures: failed });
+ setBulkPlans({ actions: staleActions, loading: false, failure: null, stale: true, failures: failed });
setBulkResult(failed.length === 0
? null
: { tone: "err", text: t("integrations.bulk.partial", { clients: failed.join("; ") }) });
@@ -680,62 +648,29 @@ export default function IntegrationsOverview({
}
};
- const requestFilePlan = async (
- row: OverviewRow,
- operation: Exclude,
- /** Recovery replaces the failed plan it is retrying; the first request never does. */
- replacing = false,
- ) => {
- if (!row.status || (plannedCard && !replacing)) return;
+ const requestFilePlan = async (row: OverviewRow, operation: Exclude) => {
+ if (!row.status || plannedCard) return;
const controller = new AbortController();
const generation = cardPreviewGenerationRef.current + 1;
cardPreviewGenerationRef.current = generation;
cardPreviewAbortRef.current?.abort();
cardPreviewAbortRef.current = controller;
- setPlannedCard({ row, operation, plan: null, loading: true, failure: null, recovery: null });
+ setPlannedCard({ row, operation, plan: null, loading: true, failure: null });
try {
const plan = await previewIntegrationMutation(apiBase, row.status.clientId, operation, controller.signal);
if (controller.signal.aborted || generation !== cardPreviewGenerationRef.current) return;
cardPreviewAbortRef.current = null;
- setPlannedCard({ row, operation, plan, loading: false, failure: null, recovery: null });
+ setPlannedCard({ row, operation, plan, loading: false, failure: null });
} catch (error) {
if (controller.signal.aborted || generation !== cardPreviewGenerationRef.current) return;
cardPreviewAbortRef.current = null;
- /*
- * No retained roster is NOT a dead end. Closing the dialog, as this used
- * to, looked like the button did nothing; keep the dialog and offer the
- * one read that makes the next preview answerable.
- */
- const recovery = previewRecoveryFor(error);
- setPlannedCard({
- row, operation, plan: null, loading: false, recovery,
- failure: recovery === null ? t("integrations.preview.failed") : null,
- });
- }
- };
-
- /*
- * Read the collection again, then re-ask for the plan.
- *
- * The collection read is an ordinary authoritative load and is what retains
- * the export roster the preview reads. Waiting for it — rather than the
- * fire-and-forget refresh() — is the whole recovery; nothing here writes a
- * client file, and the preview stays side-effect free.
- */
- const recoverPlannedCard = async () => {
- const planned = plannedCard;
- if (!planned) return;
- // Close and any newer preview move the generation; a stale recovery must not revive it.
- const generation = cardPreviewGenerationRef.current;
- try {
- await loadIntegrationStates(apiBase);
- } catch {
- // The preview will answer (or refuse) again; a second error banner here
- // would only obscure the one the user can act on.
+ if (isIntegrationPreviewUnavailable(error)) {
+ setPlannedCard(null);
+ refresh();
+ return;
+ }
+ setPlannedCard({ row, operation, plan: null, loading: false, failure: t("integrations.preview.failed") });
}
- if (generation !== cardPreviewGenerationRef.current) return;
- refresh();
- await requestFilePlan(planned.row, planned.operation, true);
};
const closePlannedCard = () => {
@@ -785,12 +720,9 @@ export default function IntegrationsOverview({
result={cardResults[row.id] ?? null}
onOpen={() => navigateHash(row.hash)}
onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null}
- onOverwrite={row.status !== null && row.status.clientId !== "aside" && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed
+ onOverwrite={row.status !== null && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed
? () => void requestFilePlan(row, "overwrite")
: null}
- onChooseProfile={row.status?.clientId === "aside" && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed
- ? () => navigateHash(row.hash)
- : null}
/>
);
@@ -1006,8 +938,6 @@ export default function IntegrationsOverview({
plan={plannedCard.plan}
planLoading={plannedCard.loading}
planFailure={plannedCard.failure}
- previewRecovery={plannedCard.recovery}
- onPreviewRecovery={recoverPlannedCard}
onClose={closePlannedCard}
onConfirm={async plan => {
if (!plan) return;
@@ -1024,8 +954,6 @@ export default function IntegrationsOverview({
planStale={bulkPlans.stale}
planLoading={bulkPlans.loading}
planFailure={bulkPlans.failure}
- previewRecovery={bulkPlans.recovery}
- onPreviewRecovery={recoverBulkPlans}
onClose={closeBulkPlans}
onConfirm={async () => { await disableAll(bulkPlans); }}
/>
diff --git a/gui/src/pages/integrations/preview-recovery.ts b/gui/src/pages/integrations/preview-recovery.ts
deleted file mode 100644
index 2fbf7510bbf..00000000000
--- a/gui/src/pages/integrations/preview-recovery.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { isIntegrationPreviewUnavailable } from "./integration-api";
-
-/**
- * What a preview that answered 409 `integration_preview_unavailable` means, and the one
- * action that can honestly fix it.
- *
- * The server refuses rather than gathering, because a preview that discovered models would
- * refresh credentials and write the provider cache — a write wearing a read's name. The
- * retained roster is filled by an ORDINARY load instead. On the dashboard that load is the
- * same collection read the Integrations page already performs on mount, so the recovery is
- * "read the page's own data again, then plan": no new write path, and the preview stays a
- * read.
- *
- * `null` means the error is not this refusal, so the caller should surface its ordinary
- * failure copy.
- */
-export type PreviewRecovery = "catalog-unavailable";
-
-export function previewRecoveryFor(error: unknown): PreviewRecovery | null {
- return isIntegrationPreviewUnavailable(error) ? "catalog-unavailable" : null;
-}
diff --git a/gui/tests/integrations-preview-recovery.test.tsx b/gui/tests/integrations-preview-recovery.test.tsx
deleted file mode 100644
index dc979748967..00000000000
--- a/gui/tests/integrations-preview-recovery.test.tsx
+++ /dev/null
@@ -1,315 +0,0 @@
-import { afterEach, beforeEach, expect, test } from "bun:test";
-import { Window } from "happy-dom";
-import { act } from "react";
-import type { ReactElement } from "react";
-import type { Root } from "react-dom/client";
-
-/**
- * Two ways a reported-conflict action could not produce a plan.
- *
- * 1. The Overview's Replace button for Aside sent an aggregate
- * `/api/client-integrations/preview` with no profile, which the server must
- * refuse: a plan describes one independently changing file. The Aside
- * surface already owns per-profile conflicts, so the card's action belongs
- * there rather than in a dead dialog.
- * 2. A preview answers 409 `integration_preview_unavailable` when no export
- * roster has been retained yet, or when a provider cache revision retired
- * it. The Overview used to close the dialog and leave the user with a
- * button that appeared to do nothing. Recovery has to be explicit: an
- * ordinary collection read gathers the roster (the same load the page
- * already performs), and only then is the preview asked again. The preview
- * itself stays side-effect free.
- */
-
-const globals = [
- "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT",
-] as const;
-
-let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>;
-let testWindow: Window;
-let container: HTMLElement;
-let root: Root | null = null;
-let apiBase = "";
-type RecordedRequest = { path: string; method: string; body: unknown };
-let requests: RecordedRequest[];
-let clients: Record[];
-let preview: (body: Record) => Response;
-
-function json(body: unknown, status = 200): Response {
- return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
-}
-
-function fileStatus(overrides: Record = {}): Record {
- return {
- clientId: "hermes", state: "current", installed: true,
- configPath: "/tmp/home/.hermes/config.yaml", snapshotCount: 1, retentionDegraded: false,
- ...overrides,
- };
-}
-
-function asideStatus(overrides: Record = {}): Record {
- return fileStatus({ clientId: "aside", configPath: "/tmp/aside/u/1/models.json", ...overrides });
-}
-
-function plan(clientId: string, operation: string, extra: Record = {}) {
- // The client parser accepts only a p:<32 lowercase hex> fingerprint.
- const digit = ({ apply: "1", overwrite: "2", disable: "3", restore: "4" } as Record)[operation] ?? "9";
- return {
- version: 1, clientId, operation,
- state: operation === "apply" ? "absent" : "current", foreignEdit: "none",
- changes: [
- { kind: operation === "disable" ? "remove" : "replace", path: "providers.opencodex" },
- { kind: "snapshot", path: "$snapshot" }, { kind: "ownership", path: "$ownership" },
- { kind: "journal", path: "$journal" },
- ],
- fingerprint: `p1:${digit.repeat(32)}`,
- canApply: true, willChange: true,
- ...extra,
- };
-}
-
-beforeEach(() => {
- previousGlobals = Object.fromEntries(
- globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
- ) as typeof previousGlobals;
- testWindow = new Window({ url: "http://localhost/#integrations" });
- Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" });
- Object.defineProperties(globalThis, {
- document: { configurable: true, value: testWindow.document },
- window: { configurable: true, value: testWindow },
- navigator: { configurable: true, value: testWindow.navigator },
- localStorage: { configurable: true, value: testWindow.localStorage },
- sessionStorage: { configurable: true, value: testWindow.sessionStorage },
- IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true },
- });
- requests = [];
- apiBase = `http://ocx-preview-recovery-${Math.random().toString(36).slice(2)}.invalid`;
- clients = [fileStatus()];
- preview = body => json(plan(String(body.clientId), String(body.operation)));
- const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = new URL(String(input instanceof Request ? input.url : input));
- const request = {
- path: url.pathname + url.search,
- method: (init?.method ?? "GET").toUpperCase(),
- body: init?.body ? JSON.parse(String(init.body)) : undefined,
- };
- requests.push(request);
- if (request.method === "POST" && /\/preview$/.test(url.pathname)) return preview(request.body as Record);
- if (url.pathname.includes("/journal")) return json({ operations: [] });
- if (url.pathname === "/api/client-integrations") return json({ clients });
- const profileState = url.pathname.match(/^\/api\/client-integrations\/aside\/profiles\/(\d+)$/);
- if (profileState) {
- const profileId = Number(profileState[1]);
- return json(clients.find(row => row.profileId === profileId) ?? asideStatus({ profileId, current: false, enabled: true }));
- }
- if (url.pathname === "/api/startup-health") return json({ routingInjected: false, status: "native", recommendedCommand: null });
- if (url.pathname === "/api/keys") return json({ keys: [] });
- if (url.pathname === "/api/claude-desktop/status") {
- return json({ desiredEnabled: true, installed: true, observedKind: "standard", applied: false, stale: false, activeProfile: null, appliedAt: null });
- }
- if (url.pathname === "/api/native-integrations") return json({ clients: [] });
- if (url.pathname === "/api/claude-code") return json({ enabled: false });
- if (url.pathname === "/api/grok") return json({ present: false, models: [] });
- if (url.pathname === "/api/codex-agent-roles") return json({ lazycodex: { detected: false }, omoJsonc: null, roles: [] });
- if (url.pathname === "/api/subagent-models") return json({ available: [] });
- if (request.method === "PUT") {
- const scoped = url.pathname.match(/^\/api\/client-integrations\/aside\/profiles\/(\d+)$/);
- return scoped
- ? json({ ok: true, clientId: "aside", profileId: Number(scoped[1]), changed: true, state: "current", message: "updated" })
- : json({ ok: true, clientId: "hermes", changed: true, state: "current", message: "updated" });
- }
- return json({ error: `Unexpected request: ${request.path}` }, 404);
- }) as typeof fetch;
- Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch });
- Object.defineProperty(testWindow, "fetch", { configurable: true, value: mockFetch });
- container = testWindow.document.createElement("div") as unknown as HTMLElement;
- testWindow.document.body.appendChild(container as never);
-});
-
-afterEach(async () => {
- if (root) {
- const mounted = root;
- await act(async () => { mounted.unmount(); });
- root = null;
- }
- testWindow.close();
- for (const key of globals) {
- const descriptor = previousGlobals[key];
- if (descriptor) Object.defineProperty(globalThis, key, descriptor);
- else Reflect.deleteProperty(globalThis, key);
- }
-});
-
-async function settle(ms = 40): Promise {
- await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, ms)); });
-}
-
-async function mount(node: () => Promise<{ element: ReactElement; name: string }>): Promise {
- const [{ createRoot }, { LanguageProvider }, loaded] = await Promise.all([
- import("react-dom/client"), import("../src/i18n/provider"), node(),
- ]);
- await act(async () => {
- root = createRoot(container);
- root.render({loaded.element});
- });
- await settle();
-}
-
-function buttons(): HTMLButtonElement[] {
- return Array.from(container.querySelectorAll("button"));
-}
-
-function buttonByText(text: string): HTMLButtonElement | undefined {
- return buttons().find(button => (button.textContent ?? "").trim() === text);
-}
-
-test("a conflicted Aside card routes to the per-profile surface instead of an unprofiled preview", async () => {
- clients = [asideStatus({ state: "conflict", reason: "unowned-key" })];
- const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview");
- await mount(async () => ({ name: "overview", element: }));
-
- // The aggregate preview cannot describe one profile; the card must not try.
- const action = buttonByText("Choose profile");
- expect(action).toBeDefined();
- expect(buttonByText("Replace")).toBeUndefined();
-
- await act(async () => { action!.click(); });
- expect(testWindow.location.hash).toBe("#integrations/aside");
- expect(requests.filter(request => request.path.endsWith("/api/client-integrations/preview"))).toHaveLength(0);
- expect(container.querySelector("dialog[open]")).toBeNull();
-});
-
-test("an unavailable preview keeps the dialog open and retries after an ordinary catalog load", async () => {
- clients = [fileStatus({ state: "conflict", reason: "unowned-key" })];
- let previews = 0;
- let collections = 0;
- let collectionsAtFirstPreview = -1;
- preview = body => {
- previews += 1;
- if (previews === 1) {
- collectionsAtFirstPreview = collections;
- return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409);
- }
- if (collections <= collectionsAtFirstPreview) {
- return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409);
- }
- return json(plan(String(body.clientId), String(body.operation)));
- };
- const originalFetch = globalThis.fetch;
- Object.defineProperty(globalThis, "fetch", {
- configurable: true,
- value: (async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = String(input instanceof Request ? input.url : input);
- if ((init?.method ?? "GET").toUpperCase() === "GET" && url.endsWith("/api/client-integrations")) collections += 1;
- return originalFetch(input, init);
- }) as typeof fetch,
- });
- Object.defineProperty(testWindow, "fetch", { configurable: true, value: globalThis.fetch });
-
- const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview");
- await mount(async () => ({ name: "overview", element: }));
-
- await act(async () => { buttonByText("Replace")!.click(); });
- await settle();
-
- const dialog = container.querySelector("dialog[open]");
- expect(dialog).not.toBeNull();
- const retry = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Load catalog and retry") as HTMLButtonElement | undefined;
- expect(retry).toBeDefined();
- expect(dialog!.textContent).toContain("model catalog is not loaded");
- // No plan exists yet, so confirming must not look available.
- const refusedConfirm = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement;
- expect(refusedConfirm.disabled).toBe(true);
-
- await act(async () => { retry!.click(); });
- await settle();
-
- expect(collections).toBeGreaterThan(collectionsAtFirstPreview);
- const confirmed = container.querySelector("dialog[open]")!;
- expect(confirmed.textContent).toContain("Operation: Replace");
- const confirm = Array.from(confirmed.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement;
- expect(confirm.disabled).toBe(false);
- await act(async () => { confirm.click(); });
- await settle();
- expect(requests.find(request => request.method === "PUT")?.body).toEqual({
- enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan("hermes", "overwrite").fingerprint,
- });
-});
-
-test("a per-profile Aside overwrite offers the same catalog retry and keeps its scope", async () => {
- clients = [asideStatus({ profileId: 4, current: false, enabled: true, state: "conflict", reason: "foreign-edit" })];
- let previews = 0;
- preview = body => {
- previews += 1;
- if (previews === 1) return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409);
- return json(plan("aside", String(body.operation), { profileId: 4 }));
- };
-
- const { default: FileIntegrationPage } = await import("../src/pages/integrations/FileIntegrationPage");
- await mount(async () => ({ name: "aside profile", element: }));
-
- const replace = buttonByText("Replace");
- expect(replace).toBeDefined();
- await act(async () => { replace!.click(); });
- await settle();
-
- const dialog = container.querySelector("dialog[open]");
- expect(dialog).not.toBeNull();
- const retry = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Load catalog and retry") as HTMLButtonElement | undefined;
- expect(retry).toBeDefined();
- await act(async () => { retry!.click(); });
- await settle();
-
- expect(container.querySelector("dialog[open]")?.textContent).toContain("Operation: Replace");
- const confirm = Array.from(container.querySelector("dialog[open]")!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement;
- await act(async () => { confirm.click(); });
- await settle();
- expect(requests.find(request => request.method === "PUT")?.path).toBe("/api/client-integrations/aside/profiles/4");
- expect(requests.find(request => request.method === "PUT")?.body).toEqual({
- enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan("aside", "overwrite", { profileId: 4 }).fingerprint,
- });
-});
-
-test("a recovery read still pending when the dialog closes does not reopen it, and the retry waits for the read", async () => {
- clients = [fileStatus({ state: "conflict", reason: "unowned-key" })];
- let previews = 0;
- preview = body => {
- previews += 1;
- return previews === 1
- ? json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409)
- : json(plan(String(body.clientId), String(body.operation)));
- };
- let release: (() => void) | null = null;
- let gate: Promise | null = null;
- const originalFetch = globalThis.fetch;
- Object.defineProperty(globalThis, "fetch", {
- configurable: true,
- value: (async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = String(input instanceof Request ? input.url : input);
- if (gate && (init?.method ?? "GET").toUpperCase() === "GET" && url.endsWith("/api/client-integrations")) await gate;
- return originalFetch(input, init);
- }) as typeof fetch,
- });
- Object.defineProperty(testWindow, "fetch", { configurable: true, value: globalThis.fetch });
-
- const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview");
- await mount(async () => ({ name: "overview", element: }));
- await act(async () => { buttonByText("Replace")!.click(); });
- await settle();
- expect(container.querySelector("dialog[open]")).not.toBeNull();
-
- gate = new Promise(resolve => { release = resolve; });
- await act(async () => { buttonByText("Load catalog and retry")!.click(); });
- await settle();
- // The retry may not ask again before the roster-retaining read has answered.
- expect(previews).toBe(1);
- await act(async () => { buttonByText("Close")!.click(); });
- await settle();
- expect(container.querySelector("dialog[open]")).toBeNull();
- await act(async () => { release!(); gate = null; });
- await settle(80);
- // A dismissed action stays dismissed: no second preview, no reopened dialog.
- expect(previews).toBe(1);
- expect(container.querySelector("dialog[open]")).toBeNull();
-});
-
From e71ae8b0e81b265361332de8c36353e39fd71d2e Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 07/14] Revert "fix(update): keep bun's postinstall enabled on
npm 11 and diagnose a skipped one (#8387)"
This reverts commit ff2f5dee4a2a81a5cb1f41d0f1754fe04145180d.
---
AGENTS_INSTALL.md | 2 +-
README.md | 6 +-
bin/ocx.mjs | 2 +-
docs-site/src/components/Landing.astro | 6 +-
.../docs/fr/getting-started/for-agents.md | 2 +-
.../docs/fr/getting-started/installation.md | 4 +-
.../docs/fr/troubleshooting/windows-memory.md | 2 +-
.../docs/getting-started/for-agents.md | 2 +-
.../docs/getting-started/installation.md | 10 +-
.../docs/ja/getting-started/for-agents.md | 2 +-
.../docs/ja/getting-started/installation.md | 4 +-
.../docs/ja/troubleshooting/windows-memory.md | 2 +-
.../docs/ko/getting-started/for-agents.md | 2 +-
.../docs/ko/getting-started/installation.md | 4 +-
.../docs/ko/troubleshooting/windows-memory.md | 2 +-
.../docs/ru/getting-started/for-agents.md | 2 +-
.../docs/ru/getting-started/installation.md | 4 +-
.../docs/ru/troubleshooting/windows-memory.md | 2 +-
.../docs/tr/getting-started/for-agents.md | 2 +-
.../docs/tr/getting-started/installation.md | 4 +-
.../docs/tr/troubleshooting/windows-memory.md | 2 +-
.../docs/troubleshooting/windows-memory.md | 2 +-
.../docs/zh-cn/getting-started/for-agents.md | 2 +-
.../zh-cn/getting-started/installation.md | 4 +-
.../zh-cn/troubleshooting/windows-memory.md | 2 +-
.../docs/zh-tw/getting-started/for-agents.md | 2 +-
.../zh-tw/getting-started/installation.md | 4 +-
.../zh-tw/troubleshooting/windows-memory.md | 2 +-
readme/README.fr.md | 6 +-
readme/README.ja.md | 6 +-
readme/README.ko.md | 6 +-
readme/README.ru.md | 6 +-
readme/README.tr.md | 6 +-
readme/README.zh-CN.md | 6 +-
readme/README.zh-TW.md | 6 +-
readme/i18n-manifest.json | 14 +--
scripts/install.ps1 | 8 +-
scripts/install.sh | 8 +-
scripts/test-layout/layout.json | 4 +-
src/cli/bundled-bun-diagnostic.ts | 91 --------------
src/cli/doctor.ts | 11 --
src/lib/bun-runtime-preflight.ts | 2 +-
src/update/index.ts | 8 +-
structure/ops/desktop-supervision.md | 9 --
structure/ops/service-and-sidecars.md | 2 +-
structure/runtime.md | 2 +-
tests/ci-workflows/install-scripts.test.ts | 10 +-
.../codex-shim-runtime-preflight.test.ts | 3 +-
tests/fixtures/test-layout-expected.json | 3 +-
tests/lib/bun-runtime-preflight.test.ts | 4 +-
tests/service/doctor-bun-install.test.ts | 117 ------------------
tests/update/update-job.test.ts | 3 +-
tests/update/update-npm-allow-scripts.test.ts | 48 -------
53 files changed, 89 insertions(+), 386 deletions(-)
delete mode 100644 src/cli/bundled-bun-diagnostic.ts
delete mode 100644 tests/service/doctor-bun-install.test.ts
delete mode 100644 tests/update/update-npm-allow-scripts.test.ts
diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md
index 0bab328853d..c4a80069fd7 100644
--- a/AGENTS_INSTALL.md
+++ b/AGENTS_INSTALL.md
@@ -92,7 +92,7 @@ A host that has both a Task Scheduler entry and a WinSW service is in a conflict
### The CLI
-`npm install -g --allow-scripts=bun @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the
+`npm install -g @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the
Bun runtime it needs is bundled inside that package. There is no separate runtime to install and
no WSL layer on Windows.
diff --git a/README.md b/README.md
index 263079fb129..db98afcaf0a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ and connect OpenCode, Pi, Qoder, WorkBuddy, Kilo, Cline, T3 Code and more throug
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -94,7 +94,7 @@ providers with Codex, Claude Code, Claude Desktop, Grok Build and the tools list
### Personal install (CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically
+npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically
ocx start # proxy + dashboard on localhost:10100
```
@@ -253,7 +253,7 @@ they reach the npm package.
For agents
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # or `ocx service`
ocx init # interactive setup: writes ~/.opencodex/config.json and wires Codex
```
diff --git a/bin/ocx.mjs b/bin/ocx.mjs
index ef5b11ad33d..72eff87dfcf 100755
--- a/bin/ocx.mjs
+++ b/bin/ocx.mjs
@@ -331,7 +331,7 @@ async function runPackageManagerSelfUpdate(manager, child) {
const latestInvocation = managerInvocation(["view", `${PKG}@${selector}`, "version"]);
const installArgs = manager === "pnpm"
? ["add", "-g", "--allow-build=bun", `${PKG}@${selector}`]
- : ["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`];
+ : ["install", "-g", `${PKG}@${selector}`];
const installInvocation = managerInvocation(installArgs);
if (!latestInvocation || !installInvocation) {
console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`);
diff --git a/docs-site/src/components/Landing.astro b/docs-site/src/components/Landing.astro
index c7f68222199..99a3f92abb8 100644
--- a/docs-site/src/components/Landing.astro
+++ b/docs-site/src/components/Landing.astro
@@ -165,14 +165,14 @@ const providers: { label: string; path?: string }[] = [
];
const quickstartMini = [
- { cmd: 'npm install -g --allow-scripts=bun @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
+ { cmd: 'npm install -g @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
{ cmd: 'ocx init', note: t('Add a provider and connect Codex', '프로바이더 추가 후 Codex 연결', '添加提供商并连接 Codex', 'Добавьте провайдера и подключите Codex', 'プロバイダーを追加して Codex を接続', '新增供應商並連接 Codex', 'Sağlayıcı ekleyip Codex’i bağlayın') },
{ cmd: 'ocx start', note: t('Start the proxy', '프록시 시작', '启动代理', 'Запуск прокси', 'プロキシを起動', '啟動代理', "Proxy'yi başlatın") },
{ cmd: 'ocx claude', note: t('In another terminal: launch Claude Code through ocx', '다른 터미널에서: ocx를 거쳐 Claude Code 실행', '在另一个终端中:通过 ocx 启动 Claude Code', 'В другом терминале: запуск Claude Code через ocx', '別のターミナルで: ocx 経由で Claude Code を起動', '在另一個終端機中:透過 ocx 啟動 Claude Code', 'Başka bir terminalde: Claude Code’u ocx üzerinden başlatın') },
];
const quickstart = [
- { cmd: 'npm install -g --allow-scripts=bun @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
+ { cmd: 'npm install -g @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
{ cmd: 'ocx init', note: t('Add a provider and connect Codex', '프로바이더 추가 후 Codex 연결', '添加提供商并连接 Codex', 'Добавьте провайдера и подключите Codex', 'プロバイダーを追加して Codex を接続', '新增供應商並連接 Codex', 'Sağlayıcı ekleyip Codex’i bağlayın') },
{ cmd: 'ocx start', note: t('Start the proxy', '프록시 시작', '启动代理', 'Запуск прокси', 'プロキシを起動', '啟動代理', "Proxy'yi başlatın") },
{ cmd: 'codex "Write a hello world in Rust"', note: t('Codex now routes through ocx', 'Codex가 ocx를 경유', 'Codex 现在经由 ocx 路由', 'Codex теперь работает через ocx', 'Codex は ocx 経由に', 'Codex 現在經由 ocx 路由', 'Codex artık ocx üzerinden yönlendirilir') },
@@ -344,7 +344,7 @@ const docsMap = [
{t('Prefer the terminal?', '터미널이 더 편하다면', '更喜欢终端?', 'Предпочитаете терминал?', 'ターミナル派ですか?', '偏好終端機?', 'Terminali mi tercih edersiniz?')}
- npm install -g --allow-scripts=bun @bitkyc08/opencodex
+ npm install -g @bitkyc08/opencodex{t('Installation guide', '설치 가이드', '安装指南', 'Руководство по установке', 'インストールガイド', '安裝指南', 'Kurulum kılavuzu')}{t('All releases and checksums', '전체 릴리스와 체크섬', '所有版本与校验和', 'Все релизы и контрольные суммы', 'すべてのリリースとチェックサム', '所有版本與校驗碼', 'Tüm sürümler ve sağlama toplamları')}{t('Desktop app guide', '데스크톱 앱 가이드', '桌面应用指南', 'Руководство по приложению', 'デスクトップアプリガイド', '桌面應用程式指南', 'Masaüstü uygulaması kılavuzu')}
diff --git a/docs-site/src/content/docs/fr/getting-started/for-agents.md b/docs-site/src/content/docs/fr/getting-started/for-agents.md
index 671d1d01e02..3e962903062 100644
--- a/docs-site/src/content/docs/fr/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/fr/getting-started/for-agents.md
@@ -13,7 +13,7 @@ disponible pour une configuration interactive ; consultez le [tableau de bord w
Installez le package publié et confirmez que `ocx` est sur `PATH` :
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/fr/getting-started/installation.md b/docs-site/src/content/docs/fr/getting-started/installation.md
index 456907e4e9e..65fb3d91133 100644
--- a/docs-site/src/content/docs/fr/getting-started/installation.md
+++ b/docs-site/src/content/docs/fr/getting-started/installation.md
@@ -19,7 +19,7 @@ ChatGPT lorsqu’un modèle routé en a besoin.
## Installer
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
:::note[npm a-t-il bloqué le script postinstall de bun ?]
@@ -52,7 +52,7 @@ les entrées du catalogue n’accordent aucun accès par elles-mêmes. N’utili
tester des versions opencodex non encore publiées :
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md b/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md
index b0d3f4476db..cae4dce2521 100644
--- a/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md
@@ -44,4 +44,4 @@ Si vous essayez l’une de ces solutions avec une charge Windows réelle, veuill
L’installation et la réparation du service Windows ainsi que l’installation et l’actualisation du shim Codex vérifient que l’exécutable sélectionné peut créer puis supprimer un répertoire dans la racine de configuration OpenCodex. Ce contrôle de politique d’écriture ne corrige pas la mémoire. Un échec refuse l’opération avant tout arrêt, téléchargement, préparation ou écriture de jeton ou de lanceur. La réparation automatique du shim est différée avec des instructions et le démarrage continue. Les shims sains ou désactivés ne sont pas testés. Aucun autre runtime n’est recherché. Une première installation du planificateur enregistre sa tâche et revendique la racine de configuration avant ce contrôle ; en cas de refus, cet enregistrement est annulé et la racine revendiquée est conservée pour une nouvelle tentative.
-> Le runtime Bun sélectionné ne peut pas créer puis supprimer un répertoire de configuration. La politique d’application Windows peut interdire les écritures depuis cet emplacement. Avant de lancer `ocx`, définissez `OPENCODEX_BUN_PATH` vers un exécutable Bun de confiance autorisé par votre politique, ou réinstallez avec `npm install -g --allow-scripts=bun @bitkyc08/opencodex`, puis réessayez. Aucun autre runtime n’a été sélectionné.
+> Le runtime Bun sélectionné ne peut pas créer puis supprimer un répertoire de configuration. La politique d’application Windows peut interdire les écritures depuis cet emplacement. Avant de lancer `ocx`, définissez `OPENCODEX_BUN_PATH` vers un exécutable Bun de confiance autorisé par votre politique, ou réinstallez avec `npm install -g @bitkyc08/opencodex`, puis réessayez. Aucun autre runtime n’a été sélectionné.
diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md
index 9bba3f1bfc1..b02c39812a5 100644
--- a/docs-site/src/content/docs/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/getting-started/for-agents.md
@@ -13,7 +13,7 @@ available for interactive configuration; see [Web Dashboard](/guides/web-dashboa
Install the published package and confirm that `ocx` is on `PATH`:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md
index a2660d4c128..7eab3cd9117 100644
--- a/docs-site/src/content/docs/getting-started/installation.md
+++ b/docs-site/src/content/docs/getting-started/installation.md
@@ -30,7 +30,7 @@ for one package invocation. That bypass does not repair the Desktop record, shim
## Install
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
With pnpm 10.4 or later:
@@ -70,12 +70,6 @@ npm install -g --allow-scripts=bun @bitkyc08/opencodex
# if the original install used sudo, keep using sudo:
sudo npm install -g --allow-scripts=bun @bitkyc08/opencodex
```
-
-On Windows a running proxy keeps `node_modules\bun\bin` open, so a manual
-`npm install -g` fails with `EBUSY`. `ocx update` stops the proxy and restarts the service itself;
-for a manual reinstall, stop it first (`ocx stop`), install, then restart through the owning service
-(`ocx service restart`) or `ocx start` when unmanaged. `ocx doctor` prints this sequence when it finds
-a package install whose bundled runtime never unpacked.
:::
Verify both command aliases are on your `PATH`:
@@ -122,7 +116,7 @@ the catalog entries do not grant access by themselves. Use the preview channel o
unreleased opencodex builds:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/ja/getting-started/for-agents.md b/docs-site/src/content/docs/ja/getting-started/for-agents.md
index db3261538f7..9eb2aa29574 100644
--- a/docs-site/src/content/docs/ja/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/ja/getting-started/for-agents.md
@@ -10,7 +10,7 @@ description: ユーザー同意の境界を越えずに、エージェント主
公開されたパッケージをインストールし、`ocx` が `PATH` 上にあることを確認します。
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md
index 3ac4677d744..49b50f7d37d 100644
--- a/docs-site/src/content/docs/ja/getting-started/installation.md
+++ b/docs-site/src/content/docs/ja/getting-started/installation.md
@@ -18,7 +18,7 @@ opencodex をインストールすると同じ実行ファイルを指す `ocx`
## インストール
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
:::note[npm が bun の postinstall をブロックした?]
@@ -63,7 +63,7 @@ GPT-5.6 Sol/Terra/Luna カタログ情報がすでに含まれています。た
ありません。まだ正式配布されていない opencodex ビルドを試す場合のみ preview チャネルを使ってください:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md
index 3bdc80e3f68..774933a39f3 100644
--- a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md
@@ -50,4 +50,4 @@ Windows では、#32111 クラッシュを回避するために、opencodex は
Windows のサービスのインストール・修復と Codex shim のインストール・更新は、選択した実行ファイルが OpenCodex 設定ルート内でディレクトリを作成・削除できるか確認します。これはメモリ修正ではなく書き込みポリシーの確認です。失敗するとサービス停止、ダウンロード、ステージング、トークンやランチャーの書き込み前に拒否します。shim の自動修復は案内付きで延期され、起動は続行します。正常または無効な shim は検査しません。別のランタイムを自動探索しません。 初回のスケジューラーインストールでは、この確認の前にタスクを登録し設定ルートを確保します。拒否された場合は新しい登録を取り消し、確保したルートは再試行用に残します。
-> 選択した Bun ランタイムが設定ディレクトリ内でディレクトリを作成・削除できませんでした。Windows のアプリケーションポリシーがこの実行ファイルの位置からの書き込みを拒否している可能性があります。`ocx` 起動前にポリシーが許可する信頼できる Bun のパスを `OPENCODEX_BUN_PATH` に設定するか、`npm install -g --allow-scripts=bun @bitkyc08/opencodex` で再インストールして再試行してください。別のランタイムは選択していません。
+> 選択した Bun ランタイムが設定ディレクトリ内でディレクトリを作成・削除できませんでした。Windows のアプリケーションポリシーがこの実行ファイルの位置からの書き込みを拒否している可能性があります。`ocx` 起動前にポリシーが許可する信頼できる Bun のパスを `OPENCODEX_BUN_PATH` に設定するか、`npm install -g @bitkyc08/opencodex` で再インストールして再試行してください。別のランタイムは選択していません。
diff --git a/docs-site/src/content/docs/ko/getting-started/for-agents.md b/docs-site/src/content/docs/ko/getting-started/for-agents.md
index b79ffdd7f06..4b6a07ded65 100644
--- a/docs-site/src/content/docs/ko/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/ko/getting-started/for-agents.md
@@ -10,7 +10,7 @@ description: 사용자의 동의 경계를 넘지 않으면서 에이전트가
배포된 패키지를 설치하고 `ocx`가 `PATH`에 들어 있는지 확인합니다:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md
index 909eb049228..4e2ec7dbdbe 100644
--- a/docs-site/src/content/docs/ko/getting-started/installation.md
+++ b/docs-site/src/content/docs/ko/getting-started/installation.md
@@ -18,7 +18,7 @@ opencodex를 설치하면 같은 실행 파일을 가리키는 `ocx`와 `opencod
## 설치
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
:::note[npm이 bun postinstall을 차단했다면?]
@@ -86,7 +86,7 @@ GPT-5.6 Sol/Terra/Luna 카탈로그 정보가 이미 들어 있습니다. 다만
아닙니다. 아직 정식 배포되지 않은 opencodex 빌드를 시험할 때만 preview 채널을 사용하세요:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md
index 2e0396dff32..1534ef83f4d 100644
--- a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md
@@ -44,4 +44,4 @@ Windows에서는 opencodex가 #32111 충돌을 피하기 위해 스트리밍 응
Windows 서비스 설치·복구와 Codex shim 설치·갱신은 선택한 실행 파일이 OpenCodex 설정 루트 안에 디렉터리를 만들고 제거할 수 있는지 검사합니다. 메모리 수정이 아닌 쓰기 정책 검사입니다. 실패하면 서비스 중지, 다운로드, 스테이징, 토큰·실행 파일 쓰기 전에 거부합니다. shim 자동 복구는 안내와 함께 지연되며 시작은 계속됩니다. 정상 상태 또는 비활성화된 shim은 검사하지 않습니다. 다른 런타임을 자동으로 찾지 않습니다. 처음 하는 스케줄러 설치는 이 검사 전에 작업을 등록하고 설정 루트를 확보합니다. 거부되면 새 등록은 되돌리고, 확보한 루트는 재시도를 위해 남겨 둡니다.
-> 선택한 Bun 런타임이 OpenCodex 설정 디렉터리 안에 디렉터리를 만들고 제거하지 못했습니다. Windows 애플리케이션 정책이 이 실행 파일 위치의 쓰기를 차단할 수 있습니다. `ocx` 실행 전에 정책이 허용하는 신뢰할 수 있는 Bun 실행 파일로 `OPENCODEX_BUN_PATH`를 설정하거나, `npm install -g --allow-scripts=bun @bitkyc08/opencodex`로 다시 설치한 뒤 재시도하세요. 다른 런타임은 선택하지 않았습니다.
+> 선택한 Bun 런타임이 OpenCodex 설정 디렉터리 안에 디렉터리를 만들고 제거하지 못했습니다. Windows 애플리케이션 정책이 이 실행 파일 위치의 쓰기를 차단할 수 있습니다. `ocx` 실행 전에 정책이 허용하는 신뢰할 수 있는 Bun 실행 파일로 `OPENCODEX_BUN_PATH`를 설정하거나, `npm install -g @bitkyc08/opencodex`로 다시 설치한 뒤 재시도하세요. 다른 런타임은 선택하지 않았습니다.
diff --git a/docs-site/src/content/docs/ru/getting-started/for-agents.md b/docs-site/src/content/docs/ru/getting-started/for-agents.md
index c443d881925..37917a311b3 100644
--- a/docs-site/src/content/docs/ru/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/ru/getting-started/for-agents.md
@@ -14,7 +14,7 @@ description: Установите и используйте opencodex из аг
Установите опубликованный пакет и убедитесь, что `ocx` доступен в `PATH`:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md
index b48185a1c0f..ca0fe20b3e4 100644
--- a/docs-site/src/content/docs/ru/getting-started/installation.md
+++ b/docs-site/src/content/docs/ru/getting-started/installation.md
@@ -19,7 +19,7 @@ opencodex устанавливает два эквивалентных имен
## Установка
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
:::note[npm заблокировал postinstall-скрипт bun?]
@@ -66,7 +66,7 @@ ChatGPT, OpenAI по API-ключу, OpenRouter и эксперименталь
Используйте канал preview только для тестирования ещё не выпущенных сборок opencodex:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md
index 401ea3fd65e..67a7ea9c7c7 100644
--- a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md
@@ -91,4 +91,4 @@ opencodex поставляет рантайм Bun (сейчас это **1.3.14*
Установка и восстановление службы Windows, а также установка и обновление shim Codex проверяют, может ли выбранный исполняемый файл создать и удалить каталог внутри корня конфигурации OpenCodex. Это проверка политики записи, а не исправление памяти. При неудаче операция отклоняется до остановки службы, загрузки, подготовки или записи токена и запускающего файла. Автовосстановление shim откладывается с рекомендацией, запуск продолжается. Исправный или отключённый shim не проверяется. Поиск другой среды не выполняется. При первой установке через планировщик задача регистрируется и корень конфигурации закрепляется до этой проверки; при отказе новая регистрация откатывается, а закреплённый корень остаётся для повторной попытки.
-> Выбранная среда Bun не смогла создать и удалить каталог конфигурации. Политика приложений Windows может запрещать запись из этого расположения. До запуска `ocx` задайте `OPENCODEX_BUN_PATH` на доверенный исполняемый файл Bun, разрешённый политикой, либо переустановите через `npm install -g --allow-scripts=bun @bitkyc08/opencodex` и повторите попытку. Другая среда не выбиралась.
+> Выбранная среда Bun не смогла создать и удалить каталог конфигурации. Политика приложений Windows может запрещать запись из этого расположения. До запуска `ocx` задайте `OPENCODEX_BUN_PATH` на доверенный исполняемый файл Bun, разрешённый политикой, либо переустановите через `npm install -g @bitkyc08/opencodex` и повторите попытку. Другая среда не выбиралась.
diff --git a/docs-site/src/content/docs/tr/getting-started/for-agents.md b/docs-site/src/content/docs/tr/getting-started/for-agents.md
index 7e4f295b692..54e7023ccf2 100644
--- a/docs-site/src/content/docs/tr/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/tr/getting-started/for-agents.md
@@ -15,7 +15,7 @@ kalır; bkz. [Web Kontrol Paneli](/tr/guides/web-dashboard/).
Yayınlanan paketi kurun ve `ocx`'in `PATH` üzerinde olduğunu doğrulayın:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/tr/getting-started/installation.md b/docs-site/src/content/docs/tr/getting-started/installation.md
index 4d410758294..14f26330d21 100644
--- a/docs-site/src/content/docs/tr/getting-started/installation.md
+++ b/docs-site/src/content/docs/tr/getting-started/installation.md
@@ -20,7 +20,7 @@ duyduğunda ChatGPT oturumunuzu da kullanabilir.
## Kurulum
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
:::note[npm bun postinstall betiğini engelledi mi?]
@@ -54,7 +54,7 @@ erişim sağlamaz. Önizleme kanalını yalnızca yayınlanmamış opencodex
derlemelerini test etmek için kullanın:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md b/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md
index 5f09debff7c..260b35816b8 100644
--- a/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md
@@ -111,4 +111,4 @@ doğrulama tam olarak budur.
Windows hizmet kurulumu ve onarımı ile Codex shim kurulumu ve yenilemesi, seçilen yürütülebilir dosyanın OpenCodex yapılandırma kökünde bir dizin oluşturup kaldırabildiğini denetler. Bu bir yazma politikası denetimidir; bellek düzeltmesi değildir. Başarısızlık hizmet durdurma, indirme, hazırlama, belirteç veya başlatıcı yazma işlemlerinden önce reddedilir. Otomatik shim onarımı yönlendirmeyle ertelenir ve başlangıç devam eder. Sağlıklı veya devre dışı shim denetlenmez. Başka çalışma zamanı aranmaz. İlk zamanlayıcı kurulumu bu denetimden önce görevini kaydeder ve yapılandırma kökünü sahiplenir; ret durumunda yeni kayıt geri alınır ve sahiplenilen kök yeniden deneme için kalır.
-> Seçilen Bun çalışma zamanı yapılandırma dizininde bir dizin oluşturup kaldıramadı. Windows uygulama politikası bu yürütülebilir konumundan yazmayı engelleyebilir. `ocx` başlatılmadan önce `OPENCODEX_BUN_PATH` değerini politikanızın izin verdiği güvenilir Bun yürütülebilir dosyasına ayarlayın veya `npm install -g --allow-scripts=bun @bitkyc08/opencodex` ile yeniden kurup tekrar deneyin. Başka çalışma zamanı seçilmedi.
+> Seçilen Bun çalışma zamanı yapılandırma dizininde bir dizin oluşturup kaldıramadı. Windows uygulama politikası bu yürütülebilir konumundan yazmayı engelleyebilir. `ocx` başlatılmadan önce `OPENCODEX_BUN_PATH` değerini politikanızın izin verdiği güvenilir Bun yürütülebilir dosyasına ayarlayın veya `npm install -g @bitkyc08/opencodex` ile yeniden kurup tekrar deneyin. Başka çalışma zamanı seçilmedi.
diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md
index 50bb093fd21..65ee95c07ab 100644
--- a/docs-site/src/content/docs/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md
@@ -146,4 +146,4 @@ the verification this mitigation is waiting on.
Windows service installation, repair and Codex shim installation/refresh check whether the selected executable can create and remove a directory inside the OpenCodex config root. This is a write-policy check, not a memory fix. Failure refuses before service stops, downloads, staging, token or launcher writes. Automatic shim repair is deferred with guidance and startup continues. Healthy or disabled shims do not probe. No alternate runtime is discovered. A first-time scheduler install registers its task and claims the config root before this check; on refusal that new registration is rolled back and the claimed root stays for a retry.
-> The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. Windows application policy may deny writes from this executable location. Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, or reinstall opencodex with npm install -g --allow-scripts=bun @bitkyc08/opencodex, then retry. No alternate runtime was selected.
+> The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. Windows application policy may deny writes from this executable location. Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected.
diff --git a/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md b/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md
index 182b79a38ab..80970e9601c 100644
--- a/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md
@@ -10,7 +10,7 @@ description: 为受代理驱动或脚本控制的终端安装并操作 opencodex
安装已发布的包,并确认 `ocx` 已在 `PATH` 中:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md
index e4d017e72b3..335debd845d 100644
--- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md
+++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md
@@ -18,7 +18,7 @@ vision 和网络搜索 sidecar 也可以使用你的 ChatGPT 登录凭据。
## 安装
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
:::note[npm 拦截了 bun postinstall?]
@@ -61,7 +61,7 @@ GPT-5.6 Sol/Terra/Luna 目录信息,但这些条目本身不会授予上游模
opencodex 构建时,才需要使用 preview 渠道:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md
index bde42e03b21..b77e5f2d7c1 100644
--- a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md
@@ -44,4 +44,4 @@ opencodex 打包了 Bun 运行时(当前为 **1.3.14**)。这类内存增长
Windows 服务安装、修复及 Codex shim 安装、刷新会检查所选可执行文件能否在 OpenCodex 配置根目录内创建并删除目录。这是写入策略检查,并非内存修复。失败时会在停止服务、下载、暂存以及写入令牌或启动器前拒绝操作。shim 自动修复会延后并显示指引,启动继续。正常或禁用的 shim 不会检查。不自动寻找其他运行时。 首次计划任务安装会在此检查前注册任务并认领配置根目录;被拒绝时会回滚新注册,已认领的根目录保留以便重试。
-> 所选 Bun 运行时无法在配置目录内创建并删除目录。Windows 应用策略可能禁止来自此可执行文件位置的写入。在启动 `ocx` 前,将 `OPENCODEX_BUN_PATH` 设置为策略允许的可信 Bun 可执行文件,或通过 `npm install -g --allow-scripts=bun @bitkyc08/opencodex` 重新安装后重试。未选择其他运行时。
+> 所选 Bun 运行时无法在配置目录内创建并删除目录。Windows 应用策略可能禁止来自此可执行文件位置的写入。在启动 `ocx` 前,将 `OPENCODEX_BUN_PATH` 设置为策略允许的可信 Bun 可执行文件,或通过 `npm install -g @bitkyc08/opencodex` 重新安装后重试。未选择其他运行时。
diff --git a/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md b/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md
index 1438fab0353..174f4447fcc 100644
--- a/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md
+++ b/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md
@@ -10,7 +10,7 @@ description: 從 agent 驅動或腳本化終端機安裝並操作 opencodex,
安裝已發布的套件,並確認 `ocx` 在 `PATH` 上:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx --version
```
diff --git a/docs-site/src/content/docs/zh-tw/getting-started/installation.md b/docs-site/src/content/docs/zh-tw/getting-started/installation.md
index 1e2e1018e4b..f954cb7175a 100644
--- a/docs-site/src/content/docs/zh-tw/getting-started/installation.md
+++ b/docs-site/src/content/docs/zh-tw/getting-started/installation.md
@@ -18,7 +18,7 @@ vision 和網路搜尋 sidecar 也可以使用你的 ChatGPT 登入憑證。
## 安裝
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
```
:::note[npm 攔截了 bun postinstall?]
@@ -50,7 +50,7 @@ GPT-5.6 Sol/Terra/Luna 目錄資訊,但這些條目本身不會授予上游模
opencodex 建置時,才需要使用 preview 渠道:
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview
+npm install -g @bitkyc08/opencodex@preview
ocx update --tag preview
```
diff --git a/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md
index bc8cdf7e7e3..58f1d32c023 100644
--- a/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md
+++ b/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md
@@ -44,4 +44,4 @@ opencodex 內嵌 Bun runtime(目前為 **1.3.14**)。記憶體成長由已
Windows 服務安裝、修復及 Codex shim 安裝、更新會檢查所選執行檔能否在 OpenCodex 設定根目錄內建立並移除目錄。這是寫入政策檢查,而非記憶體修復。失敗時會在停止服務、下載、暫存以及寫入權杖或啟動器前拒絕操作。shim 自動修復會延後並顯示指引,啟動繼續。正常或停用的 shim 不會檢查。不自動尋找其他 runtime。 首次排程安裝會在此檢查前登錄工作並認領設定根目錄;被拒絕時會復原新登錄,已認領的根目錄保留以便重試。
-> 所選 Bun runtime 無法在設定目錄內建立並移除目錄。Windows 應用程式政策可能禁止來自此執行檔位置的寫入。在啟動 `ocx` 前,將 `OPENCODEX_BUN_PATH` 設為政策允許的可信 Bun 執行檔,或以 `npm install -g --allow-scripts=bun @bitkyc08/opencodex` 重新安裝後重試。未選擇其他 runtime。
+> 所選 Bun runtime 無法在設定目錄內建立並移除目錄。Windows 應用程式政策可能禁止來自此執行檔位置的寫入。在啟動 `ocx` 前,將 `OPENCODEX_BUN_PATH` 設為政策允許的可信 Bun 執行檔,或以 `npm install -g @bitkyc08/opencodex` 重新安裝後重試。未選擇其他 runtime。
diff --git a/readme/README.fr.md b/readme/README.fr.md
index fcb05695ccf..1a6820fbd91 100644
--- a/readme/README.fr.md
+++ b/readme/README.fr.md
@@ -15,7 +15,7 @@
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex est une passerelle locale légère entre vos outils de programmation e
### Installation personnelle (CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+ ; le runtime Bun est inclus automatiquement
+npm install -g @bitkyc08/opencodex # Node 18+ ; le runtime Bun est inclus automatiquement
ocx start # proxy + tableau de bord sur localhost:10100
```
@@ -250,7 +250,7 @@ leur arrivée dans le paquet npm.
Pour les agents
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # ou `ocx service`
ocx init # configuration interactive : écrit ~/.opencodex/config.json et relie Codex
```
diff --git a/readme/README.ja.md b/readme/README.ja.md
index ba4b91408b5..734f849f17d 100644
--- a/readme/README.ja.md
+++ b/readme/README.ja.md
@@ -15,7 +15,7 @@
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex は、コーディングツールとモデルプロバイダーをつ
### 個人向けインストール(CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18 以上。Bun ランタイムは自動で同梱されます
+npm install -g @bitkyc08/opencodex # Node 18 以上。Bun ランタイムは自動で同梱されます
ocx start # プロキシとダッシュボードが localhost:10100 で起動
```
@@ -246,7 +246,7 @@ bun run src/cli/index.ts start
エージェント向け
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # または `ocx service`
ocx init # 対話式セットアップ: ~/.opencodex/config.json を書き、Codex を接続します
```
diff --git a/readme/README.ko.md b/readme/README.ko.md
index b631de84ad8..6586139d130 100644
--- a/readme/README.ko.md
+++ b/readme/README.ko.md
@@ -15,7 +15,7 @@
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex는 코딩 도구와 모델 프로바이더를 연결하는 가벼운
### 개인 설치 (CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; Bun 런타임은 자동으로 번들됩니다
+npm install -g @bitkyc08/opencodex # Node 18+; Bun 런타임은 자동으로 번들됩니다
ocx start # 프록시 + 대시보드: localhost:10100
```
@@ -241,7 +241,7 @@ bun run src/cli/index.ts start
에이전트용
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # 또는 `ocx service`
ocx init # 대화형 설정: ~/.opencodex/config.json을 쓰고 Codex를 연결합니다
```
diff --git a/readme/README.ru.md b/readme/README.ru.md
index c1ac691fe02..0b1d8184243 100644
--- a/readme/README.ru.md
+++ b/readme/README.ru.md
@@ -15,7 +15,7 @@
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex — лёгкий локальный шлюз между вашими
### Личная установка (CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; рантайм Bun подключается автоматически
+npm install -g @bitkyc08/opencodex # Node 18+; рантайм Bun подключается автоматически
ocx start # прокси + панель управления на localhost:10100
```
@@ -252,7 +252,7 @@ bun run src/cli/index.ts start
Для агентов
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # или `ocx service`
ocx init # интерактивная настройка: пишет ~/.opencodex/config.json и подключает Codex
```
diff --git a/readme/README.tr.md b/readme/README.tr.md
index 216c2d0f570..8bb58c01bcd 100644
--- a/readme/README.tr.md
+++ b/readme/README.tr.md
@@ -15,7 +15,7 @@
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex, kodlama araçlarınızla model sağlayıcılarınız arasında çalı
### Kişisel kurulum (CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; Bun çalışma zamanı otomatik olarak paketlenir
+npm install -g @bitkyc08/opencodex # Node 18+; Bun çalışma zamanı otomatik olarak paketlenir
ocx start # proxy + kontrol paneli, localhost:10100
```
@@ -248,7 +248,7 @@ iyileştirmeleri ve henüz yayımlanmamış düzeltmeler npm paketine ulaşmadan
Ajanlar için
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # ya da `ocx service`
ocx init # etkileşimli kurulum: ~/.opencodex/config.json yazar ve Codex'i bağlar
```
diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md
index 13d4c9317a1..1c0a6ef79b6 100644
--- a/readme/README.zh-CN.md
+++ b/readme/README.zh-CN.md
@@ -15,7 +15,7 @@
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex 是连接编程工具与模型提供商的轻量级本地网关。它
### 个人安装(CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+;Bun 运行时会自动捆绑
+npm install -g @bitkyc08/opencodex # Node 18+;Bun 运行时会自动捆绑
ocx start # 代理 + 仪表板:localhost:10100
```
@@ -241,7 +241,7 @@ bun run src/cli/index.ts start
面向 agent
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # 或 `ocx service`
ocx init # 交互式设置:写入 ~/.opencodex/config.json 并接入 Codex
```
diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md
index 7843c589bf6..b48d492e0d5 100644
--- a/readme/README.zh-TW.md
+++ b/readme/README.zh-TW.md
@@ -15,7 +15,7 @@
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex 是連接程式開發工具與模型供應商的輕量級本機閘道
### 個人安裝(CLI)
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+;Bun 執行環境會自動打包
+npm install -g @bitkyc08/opencodex # Node 18+;Bun 執行環境會自動打包
ocx start # 代理 + 儀表板位於 localhost:10100
```
@@ -241,7 +241,7 @@ bun run src/cli/index.ts start
給 agent 使用
```bash
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
ocx start # 或 `ocx service`
ocx init # 互動式設定:寫入 ~/.opencodex/config.json 並接上 Codex
```
diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json
index cb937aa02f5..7fc9221d996 100644
--- a/readme/i18n-manifest.json
+++ b/readme/i18n-manifest.json
@@ -6,43 +6,43 @@
"file": "readme/README.fr.md",
"label": "Français",
"docsPath": "fr",
- "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
+ "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
},
"ko": {
"file": "readme/README.ko.md",
"label": "한국어",
"docsPath": "ko",
- "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
+ "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
},
"zh-CN": {
"file": "readme/README.zh-CN.md",
"label": "简体中文",
"docsPath": "zh-cn",
- "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
+ "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
},
"zh-TW": {
"file": "readme/README.zh-TW.md",
"label": "繁體中文",
"docsPath": "zh-tw",
- "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
+ "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
},
"ru": {
"file": "readme/README.ru.md",
"label": "Русский",
"docsPath": "ru",
- "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
+ "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
},
"ja": {
"file": "readme/README.ja.md",
"label": "日本語",
"docsPath": "ja",
- "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
+ "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
},
"tr": {
"file": "readme/README.tr.md",
"label": "Türkçe",
"docsPath": "tr",
- "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
+ "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
}
}
}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 0b3ab9c3fd5..1da608a5b99 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -22,15 +22,15 @@ if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Host "Using Node v$nodeVersion"
-# Install opencodex globally. npm 12 blocks dependency lifecycle scripts by default, and
-# bun's postinstall is what unpacks the bundled runtime; without this approval the install
-# succeeds but `ocx` cannot start (#8361). npm 8 through 11 accept the flag on global installs.
+# Install opencodex globally
+# If npm reports "install scripts blocked" for bun, rerun as:
+# npm install -g --allow-scripts=bun @bitkyc08/opencodex
# (use an elevated PowerShell if the original install was elevated)
$npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
if (-not $npm) {
$npm = Get-Command npm -ErrorAction Stop
}
-& $npm.Source install -g --allow-scripts=bun @bitkyc08/opencodex
+& $npm.Source install -g @bitkyc08/opencodex
if ($LASTEXITCODE -ne 0) {
Write-Error "npm install failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
diff --git a/scripts/install.sh b/scripts/install.sh
index 54715adc9c7..6a25db452e9 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -21,11 +21,11 @@ fi
echo "Using Node $(node --version)"
-# Install opencodex globally. npm 12 blocks dependency lifecycle scripts by default, and
-# bun's postinstall is what unpacks the bundled runtime; without this approval the install
-# succeeds but `ocx` cannot start (#8361). npm 8 through 11 accept the flag on global installs.
+# Install opencodex globally
+# If npm reports "install scripts blocked" for bun, rerun as:
+# npm install -g --allow-scripts=bun @bitkyc08/opencodex
# (keep sudo if the original install used sudo)
-npm install -g --allow-scripts=bun @bitkyc08/opencodex
+npm install -g @bitkyc08/opencodex
if ! command -v ocx &>/dev/null; then
NPM_BIN="$(npm bin -g 2>/dev/null || printf "%s/bin" "$(npm prefix -g)")"
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 1ca32d3d7ec..544dcd10adb 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -925,7 +925,7 @@
"docs-readme-memory-inventory.test.ts": "ci-workflows",
"docs-readme-translation-parity.test.ts": "ci-workflows",
"docs-remote-hub-claims.test.ts": "ci-workflows",
- "doctor-codex-envkey-readiness.test.ts": "service", "doctor-bun-install.test.ts": "service",
+ "doctor-codex-envkey-readiness.test.ts": "service",
"doctor-oauth.test.ts": "service",
"doctor-provider-apikey.test.ts": "service",
"doctor-service-memory-contract.test.ts": "service",
@@ -1839,7 +1839,7 @@
"update-async-routes.test.ts": "server",
"update-badge.test.ts": "update", "update-bounded-json.test.ts": "update", "update-checkpoint-codec.test.ts": "update", "update-contracts.test.ts": "update", "update-candidate-id.test.ts": "update", "update-external-metadata.test.ts": "update", "update-minisign.test.ts": "update", "update-root-snapshot.test.ts": "update", "update-signed-supply.test.ts": "update", "update-signed-version.test.ts": "update", "update-trust-root.test.ts": "update", "update-bun-ownership-lease.test.ts": "update",
"update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update",
- "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-npm-allow-scripts.test.ts": "update",
+ "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update",
"update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update",
"update-orchestrated-session.test.ts": "update", "update-orchestrated-entry.test.ts": "update", "update-orchestrated-apply.test.ts": "update", "update-orchestrated-arguments.test.ts": "update", "update-orchestrated-plan.test.ts": "update", "update-sync-mutex.test.ts": "update", "update-orchestrated-identity.test.ts": "update", "update-orchestrated-token.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update", "update-mise-launcher-target.test.ts": "update",
"update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update",
diff --git a/src/cli/bundled-bun-diagnostic.ts b/src/cli/bundled-bun-diagnostic.ts
deleted file mode 100644
index d9d8535a83c..00000000000
--- a/src/cli/bundled-bun-diagnostic.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * `ocx doctor` check for a package install whose bundled Bun dependency never finished
- * installing (#8361).
- *
- * npm 12 blocks dependency lifecycle scripts by default. The `bun` npm package downloads nothing
- * in its tarball: its postinstall copies the selected `@oven/bun-*` executable into `bin/`. An
- * install that skipped that script succeeds and leaves a ~450-byte placeholder, so the launcher
- * fails later with "the `bun` dependency is not installed". Doctor is where an operator looks
- * after that failure, so it names the state and the exact command that repairs it.
- *
- * The check is observe-only and never fails the doctor run: a leftover PATH Bun or an explicit
- * `OPENCODEX_BUN_PATH` can still be serving requests, which makes this a degraded install rather
- * than an unusable surface.
- */
-import { dirname, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import * as bunRuntime from "../lib/bun-runtime";
-import * as installDetection from "../update/install-detection.mjs";
-
-const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
-
-export const BUNDLED_BUN_MISSING_DETAIL = "the `bun` dependency is not installed";
-export const NPM_BUN_REPAIR_COMMAND = "npm install -g --allow-scripts=bun @bitkyc08/opencodex";
-
-type PackageInstaller = "npm" | "pnpm" | "bun" | "mise";
-
-export type BundledBunDiagnostic =
- | { status: "ready"; path: string }
- | { status: "not-applicable" }
- | { status: "missing"; installer: PackageInstaller; lines: string[] };
-
-export interface BundledBunDiagnosticDeps {
- platform?: NodeJS.Platform;
- bundledPath?: () => string | null;
- detectOwnership?: () => { installer: string; owner?: { tool: string } | null };
-}
-
-/** The command that reinstalls this package while allowing bun's postinstall. */
-export function bundledBunRepairCommand(installer: PackageInstaller, tool?: string | null): string | null {
- if (installer === "npm") return NPM_BUN_REPAIR_COMMAND;
- if (installer === "pnpm") return "pnpm add -g --allow-build=bun @bitkyc08/opencodex";
- if (installer === "bun") return "bun add -g @bitkyc08/opencodex";
- return tool ? `mise upgrade ${tool}` : null;
-}
-
-export function collectBundledBunDiagnostic(deps: BundledBunDiagnosticDeps = {}): BundledBunDiagnostic {
- const bundled = (deps.bundledPath ?? bunRuntime.bundledBunPath)();
- if (bundled) return { status: "ready", path: bundled };
-
- const ownership = (deps.detectOwnership
- ?? (() => installDetection.detectInstallOwnershipFromPath(PACKAGE_ROOT)))();
- const installer = ownership.installer;
- // Only the mise verdict carries an owner; the others have no tool to name.
- const ownerTool = "owner" in ownership ? ownership.owner?.tool ?? null : null;
- // A source checkout has no bundled dependency by design, and a standalone binary carries its
- // own runtime; neither is a skipped-postinstall symptom.
- if (installer !== "npm" && installer !== "pnpm" && installer !== "bun" && installer !== "mise") {
- return { status: "not-applicable" };
- }
-
- const repair = bundledBunRepairCommand(installer, ownerTool);
- const windows = (deps.platform ?? process.platform) === "win32";
- const lines = [
- ` !! ${BUNDLED_BUN_MISSING_DETAIL} (its postinstall was skipped or the dependency is incomplete)`,
- " Allow bun's own install script; npm 12 blocks lifecycle scripts by default,",
- " and --ignore-scripts or ignore-scripts=true in an npmrc blocks it on any npm",
- ];
- if (!repair) {
- lines.push(" This mise-owned installation has no verified tool name; upgrade it through mise.");
- return { status: "missing", installer, lines };
- }
- if (windows && installer === "npm") {
- // A running bun.exe holds node_modules/bun/bin open, so npm fails with EBUSY. The instruction
- // has to stop first; `ocx update` does this itself, but a manual reinstall does not.
- lines.push(" Stop the proxy first so Windows releases node_modules/bun/bin, then run:");
- lines.push(" ocx stop");
- lines.push(` ${repair}`);
- lines.push(" Restart afterwards: ocx service restart (or ocx start when unmanaged)");
- } else {
- lines.push(" Fix: reinstall with the package manager that owns this install:");
- lines.push(` ${repair}`);
- }
- return { status: "missing", installer, lines };
-}
-
-/** Render the doctor "Bundled Bun runtime" section lines. */
-export function formatBundledBunDiagnosticLines(diagnostic: BundledBunDiagnostic): string[] {
- if (diagnostic.status === "ready") return [` ok ready: ${diagnostic.path}`];
- if (diagnostic.status === "not-applicable") return [];
- return diagnostic.lines;
-}
diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts
index 04421d3ac65..3dea81c1020 100644
--- a/src/cli/doctor.ts
+++ b/src/cli/doctor.ts
@@ -8,7 +8,6 @@
* networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*.
*/
import { cliCommandDoctorChecks, collectCliPathDiagnostics } from "./cli-path-diagnostics";
-import { collectBundledBunDiagnostic, formatBundledBunDiagnosticLines } from "./bundled-bun-diagnostic";
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
@@ -1608,16 +1607,6 @@ export async function runDoctor(args: string[] = []): Promise {
}
}
- // The bundled dependency is the install's own runtime, distinct from whatever Bun is running
- // doctor or serving the proxy. A skipped postinstall (#8361) is invisible here otherwise.
- // A source checkout or standalone binary has no bundled `bun` dependency, so the
- // section has nothing to say there and prints no heading.
- const bundledBunLines = formatBundledBunDiagnosticLines(collectBundledBunDiagnostic());
- if (bundledBunLines.length > 0) {
- console.log("\nBundled Bun runtime");
- for (const line of bundledBunLines) console.log(line);
- }
-
console.log("\nWHAM reachability");
const probe = await probeWham();
const detail = probe.status !== null ? `status=${probe.status}` : `error=${probe.classification}`;
diff --git a/src/lib/bun-runtime-preflight.ts b/src/lib/bun-runtime-preflight.ts
index eb71a7551d4..3f3e7f6a68b 100644
--- a/src/lib/bun-runtime-preflight.ts
+++ b/src/lib/bun-runtime-preflight.ts
@@ -9,7 +9,7 @@ export const RUNTIME_PREFLIGHT_TIMEOUT_MS = 5_000;
export const RUNTIME_PREFLIGHT_GUIDANCE = "The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. "
+ "Windows application policy may deny writes from this executable location. "
+ "Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, "
- + "or reinstall opencodex with npm install -g --allow-scripts=bun @bitkyc08/opencodex, then retry. No alternate runtime was selected.";
+ + "or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected.";
export class RuntimePreflightError extends Error {
readonly code = "OCX_RUNTIME_PREFLIGHT_FAILED";
diff --git a/src/update/index.ts b/src/update/index.ts
index 68b5b101491..2b380ea3ed0 100644
--- a/src/update/index.ts
+++ b/src/update/index.ts
@@ -364,13 +364,7 @@ export function updateCommand(installer: Installer, tag: Channel, resolvedVersio
return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] };
}
const bin = "npm";
- // npm 12 blocks dependency lifecycle scripts by default, and bun's postinstall is what
- // copies the selected @oven/bun-* executable into bun/bin. Without this narrow approval
- // the install succeeds but leaves bun's un-downloaded placeholder, so the next `ocx`
- // cannot start (#8361). npm 6 through 11 run bun's postinstall anyway and install with the
- // flag on global installs, so it is unconditional rather than version-gated; some npm 11
- // releases print an "Unknown cli config" warning for it and still install.
- const args = ["install", "-g", "--allow-scripts=bun", `${PKG}@${target}`];
+ const args = ["install", "-g", `${PKG}@${target}`];
return { bin, args };
}
diff --git a/structure/ops/desktop-supervision.md b/structure/ops/desktop-supervision.md
index fb27b68cc17..1f4c2d9f216 100644
--- a/structure/ops/desktop-supervision.md
+++ b/structure/ops/desktop-supervision.md
@@ -127,15 +127,6 @@ formatting is the only allowlisted work between that authorizer and process crea
`tests/update/update-home-launcher.test.ts` and `tests/update/update-home-recovery.test.ts`
exercise refusal when preparation changes the home observation, plus stable-home starts.
-The npm install argv that `updateCommand()` (and the Node launcher's own npm self-update) builds
-always carries `--allow-scripts=bun`: npm 12 blocks dependency lifecycle scripts by default, and
-bun's postinstall is what unpacks the bundled runtime, so a bare global install would leave the
-next `ocx` unable to start (#8361). npm 8 through 11 accept the flag on global installs, so it is
-not version-gated. The npm package replacement stages beside the live tree and stops the proxy
-before the swap, which is also what releases `node_modules/bun/bin` on Windows; a manual
-`npm install -g` under a live proxy still fails with `EBUSY`, so doctor prints the stop, install
-and restart sequence for npm-owned installs instead of attempting it.
-
The bypass ledger classifies these guards as **early warning**, not enforcement:
| Guard | Surface / tier | Known limit and residual risk | Pass-through assertion |
diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md
index 01bf73c99bd..a5fbc8fad55 100644
--- a/structure/ops/service-and-sidecars.md
+++ b/structure/ops/service-and-sidecars.md
@@ -100,7 +100,7 @@ The frozen selection feeds scheduler rendering, the WinSW entry and the install-
The fresh scheduler path captures root absence and freezes the runtime at entry, stages and registers its definition, removes staging and claims config ownership, then probes with `rootWasAbsent: false` immediately before `prepare()`; a refused probe rolls back the new registration before service-manager cleanup or asset/state publication. Healthy or disabled shims do not probe; refused automatic restore defers with guidance and startup continues. Non-Windows admission performs no spawn or filesystem work, selection stays pre-dotenv and `cliEntry` stays I/O-free. That cleanup (`prepareServiceInstall`) ends every recorded, conflicting and requested manager, then — Windows only — issues the same best-effort launcher/wrapper termination `ocx stop` performs, scoped to this install's canonical script and launcher tokens (an `schtasks /end` survivor can respawn the proxy mid-install; this layer guarantees neither that the wrapper actually exited nor that the new instance comes up), then stops the tracked standalone listener; a refused or unverifiable manager stop aborts before that cleanup runs.
An absent root reaching admission is created with an exclusive non-recursive mode-0700 mkdir under an existing parent and left empty for later ownership claiming; the preflight never deletes the config root (refusal, concurrent creation, contents and replacements are all preserved). Existing roots must be real directories, not files or symlinks/junctions, and probe cleanup is non-recursive.
-A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g --allow-scripts=bun @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added. The flag is part of the contract, not advice: npm 12 blocks dependency lifecycle scripts by default and bun's postinstall is what unpacks the binary, while npm 8 through 11 accept the flag on a global install (#8361).
+A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added.
## Windows npm tray update badge
diff --git a/structure/runtime.md b/structure/runtime.md
index 7e6c538a3d2..a0cab036e61 100644
--- a/structure/runtime.md
+++ b/structure/runtime.md
@@ -121,7 +121,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour
| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. |
| `src/server/ports.ts` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. Temporary probes dispose accepted peers and wait for listener close before reporting success. |
| `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. |
-| `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` through the byte-capped direct-local transport when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. `src/cli/bundled-bun-diagnostic.ts` is the separate "Bundled Bun runtime" section: when `bundledBunPath()` finds no real binary it asks the package-layout detector who owns the install and prints that manager's repair command, because a skipped npm postinstall (npm 12 blocks lifecycle scripts by default) otherwise surfaces only later as the launcher's "the `bun` dependency is not installed" (#8361). It is observe-only and records no doctor failure — a PATH Bun or `OPENCODEX_BUN_PATH` can still serve — and on Windows the npm guidance stops the proxy before reinstalling, since a live `bun.exe` holds `node_modules/bun/bin` open. |
+| `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` through the byte-capped direct-local transport when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. |
| `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. |
| `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes. `src/providers/routed-config.ts` owns request-time provider/registry resolution and the discarded-base-URL warning memo; key selection imports that leaf instead of the router, while `src/router.ts` retains the resolver and warning-reconciliation compatibility exports. The leaf preserves GCP service-account credential markers verbatim until the Vertex adapter resolves the selected account per request. |
| `src/types.ts` | Shared config, parsed request, adapter, and event types. |
diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts
index dd3841279c9..4d3674c44f6 100644
--- a/tests/ci-workflows/install-scripts.test.ts
+++ b/tests/ci-workflows/install-scripts.test.ts
@@ -142,9 +142,7 @@ describe("install scripts", () => {
const script = await readText("scripts/install.sh");
expect(script).toContain("Node.js 18+ is required");
- // #8361: npm 12 blocks bun's postinstall by default, so a bare install produces a
- // package whose bundled runtime never unpacked. Both installers must permit that one script.
- expect(script).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
+ expect(script).toContain("npm install -g @bitkyc08/opencodex");
expect(script).toContain("command -v ocx");
expect(script).toContain("ocx help");
expect(script).not.toContain("bun install -g @bitkyc08/opencodex");
@@ -155,7 +153,7 @@ describe("install scripts", () => {
const script = await readText("scripts/install.ps1");
expect(script).toContain("Node.js 18+ is required");
- expect(script).toContain("& $npm.Source install -g --allow-scripts=bun @bitkyc08/opencodex");
+ expect(script).toContain("& $npm.Source install -g @bitkyc08/opencodex");
expect(script).toContain("$LASTEXITCODE");
expect(script).toContain("Get-Command ocx.cmd");
expect(script).toContain("Get-Command ocx");
@@ -221,9 +219,7 @@ exit 0
expect(launcher).toContain('process.argv[2] === "update"');
expect(launcher).toContain('const selector = child?.version ?? tag;');
- // #8361: the launcher's own npm reinstall must allow bun's postinstall for the same
- // reason the shipped installers do; a bare install leaves the runtime un-unpacked.
- expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]');
+ expect(launcher).toContain('["install", "-g", `${PKG}@${selector}`]');
expect(launcher).toContain('["add", "-g", "--allow-build=bun", `${PKG}@${selector}`]');
expect(launcher).toContain('const tag = updateTag(current);');
expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"');
diff --git a/tests/codex-integration/codex-shim-runtime-preflight.test.ts b/tests/codex-integration/codex-shim-runtime-preflight.test.ts
index bca8ac902d0..c4ccf7b0cf2 100644
--- a/tests/codex-integration/codex-shim-runtime-preflight.test.ts
+++ b/tests/codex-integration/codex-shim-runtime-preflight.test.ts
@@ -61,8 +61,7 @@ describe("Windows shim selected-runtime preflight", () => {
expect(maybeAutoRestoreCodexShim("start", ["start"], { env: {}, warn: message => warnings.push(message), restore: () => result,
readConfig: () => ({ config: getDefaultConfig(), source: "default", error: null }),
})).toBeUndefined();
- // #8361: the repair hint must approve bun's postinstall; npm 12 skips it by default.
- expect(warnings).toEqual([expect.stringContaining("npm install -g --allow-scripts=bun @bitkyc08/opencodex")]);
+ expect(warnings).toEqual([expect.stringContaining("npm install -g @bitkyc08/opencodex")]);
}));
}
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 263a1d61888..3c964189a0d 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -862,7 +862,6 @@
"docs-provider-discovery-limits.test.ts": "ci-workflows", "docs-provider-preset-counts.test.ts": "ci-workflows",
"docs-readme-memory-inventory.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows",
"docs-remote-hub-claims.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service",
- "doctor-bun-install.test.ts": "service",
"doctor-oauth.test.ts": "service", "doctor-provider-apikey.test.ts": "service",
"doctor-service-memory-contract.test.ts": "service", "doctor.test.ts": "codex-integration",
"download-cap-default.test.ts": "images", "download-connect-deadline-default.test.ts": "images",
@@ -1461,7 +1460,7 @@
"update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update",
"update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update",
"update-standalone.test.ts": "update",
- "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-allow-scripts.test.ts": "update",
+ "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update",
"update-orchestrated-session.test.ts": "update", "update-orchestrated-entry.test.ts": "update", "update-orchestrated-apply.test.ts": "update", "update-orchestrated-arguments.test.ts": "update", "update-orchestrated-plan.test.ts": "update", "update-sync-mutex.test.ts": "update", "update-orchestrated-identity.test.ts": "update", "update-orchestrated-token.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update",
"update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update",
"update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update",
diff --git a/tests/lib/bun-runtime-preflight.test.ts b/tests/lib/bun-runtime-preflight.test.ts
index adea703eb0a..0c7dea985ff 100644
--- a/tests/lib/bun-runtime-preflight.test.ts
+++ b/tests/lib/bun-runtime-preflight.test.ts
@@ -23,9 +23,7 @@ function refusal(run: () => void, reason: RuntimePreflightReason): void {
expect(error).toBeInstanceOf(RuntimePreflightError);
expect(error).toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason });
expect((error as Error).message).toContain("OPENCODEX_BUN_PATH");
- // #8361: the repair command must permit bun's postinstall, or npm 12 leaves the
- // bundled runtime as its placeholder and the retry fails the same way.
- expect((error as Error).message).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
+ expect((error as Error).message).toContain("npm install -g @bitkyc08/opencodex");
}
}
function child(result: Partial>): typeof spawnSync {
diff --git a/tests/service/doctor-bun-install.test.ts b/tests/service/doctor-bun-install.test.ts
deleted file mode 100644
index 0b054ef8bd0..00000000000
--- a/tests/service/doctor-bun-install.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
-import * as proxyLiveness from "../../src/server/proxy-liveness";
-import * as bunRuntime from "../../src/lib/bun-runtime";
-import * as installDetection from "../../src/update/install-detection.mjs";
-import { getDefaultConfig } from "../../src/config";
-import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { runDoctor } from "../../src/cli/doctor";
-import { collectBundledBunDiagnostic, formatBundledBunDiagnosticLines } from "../../src/cli/bundled-bun-diagnostic";
-import { removeTreeWithRetry } from "../helpers/remove-tree";
-import { STORE_BUDGET_MS } from "../helpers/test-budget";
-
-/**
- * Issue #8361: npm 12 blocks lifecycle scripts by default, so `npm install -g` leaves the
- * bundled `bun` dependency as its un-downloaded placeholder. The launcher then reports
- * "the `bun` dependency is not installed" and only a reinstall that permits bun's own
- * postinstall repairs it. Doctor must name that state and the exact command.
- *
- * The bundled runtime and package-manager detection are faked at their module seams so the
- * case exercises `runDoctor`'s real call site rather than a helper in isolation.
- */
-describe("doctor bundled Bun install guidance (#8361)", () => {
- let home: string;
- let codexHome: string;
- let previousHome: string | undefined;
- let previousCodexHome: string | undefined;
- let previousExitCode: typeof process.exitCode;
- let logged: string[];
- const restore: Array<() => void> = [];
-
- beforeEach(() => {
- home = mkdtempSync(join(tmpdir(), "ocx-doctor-bun-install-"));
- codexHome = join(home, "codex");
- mkdirSync(codexHome, { recursive: true });
- previousHome = process.env.OPENCODEX_HOME;
- previousCodexHome = process.env.CODEX_HOME;
- previousExitCode = process.exitCode;
- process.env.OPENCODEX_HOME = home;
- process.env.CODEX_HOME = codexHome;
- writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: 9, codexAutoStart: false }));
- logged = [];
- const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(" ")); });
- restore.push(() => log.mockRestore());
- // Doctor's other sections probe upstream health; this fixture must stay offline.
- const fetch = spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 503 }));
- restore.push(() => fetch.mockRestore());
- const live = spyOn(proxyLiveness, "findLiveProxy").mockResolvedValue({ pid: null, port: 9, hostname: "127.0.0.1", source: "config" });
- restore.push(() => live.mockRestore());
- });
-
- afterEach(() => {
- for (const cleanup of restore.splice(0).reverse()) cleanup();
- process.exitCode = previousExitCode;
- if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
- else process.env.OPENCODEX_HOME = previousHome;
- if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
- else process.env.CODEX_HOME = previousCodexHome;
- if (existsSync(home)) removeTreeWithRetry(home);
- });
-
- test("an npm install whose Bun postinstall was skipped prints the exact repair command", async () => {
- // A skipped postinstall leaves no real binary (bundledBunPath returns null for the
- // placeholder stub). The package layout still identifies npm as the owner.
- const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue(null);
- restore.push(() => bundled.mockRestore());
- const detection = spyOn(installDetection, "detectInstallOwnershipFromPath")
- .mockReturnValue({ installer: "npm" });
- restore.push(() => detection.mockRestore());
-
- await runDoctor([]);
- const report = logged.join("\n");
-
- expect(report).toContain("the `bun` dependency is not installed");
- expect(report).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
- }, STORE_BUDGET_MS);
-
- test("a ready bundled runtime does not suggest reinstalling", async () => {
- const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue("/opt/ocx/node_modules/bun/bin/bun");
- restore.push(() => bundled.mockRestore());
-
- await runDoctor([]);
- const report = logged.join("\n");
-
- expect(report).toContain("Bundled Bun runtime");
- expect(report).not.toContain("the `bun` dependency is not installed");
- expect(report).not.toContain("npm install -g --allow-scripts=bun");
- }, STORE_BUDGET_MS);
-
- test("a source checkout prints no empty Bundled Bun runtime heading", async () => {
- const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue(null);
- restore.push(() => bundled.mockRestore());
- const detection = spyOn(installDetection, "detectInstallOwnershipFromPath")
- .mockReturnValue({ installer: "source" });
- restore.push(() => detection.mockRestore());
-
- await runDoctor([]);
-
- expect(logged.join("\n")).not.toContain("Bundled Bun runtime");
- }, STORE_BUDGET_MS);
-
- test("a Windows npm install stops the proxy before reinstalling, since a live bun.exe holds bin", () => {
- // Windows fails the manual reinstall with EBUSY while the service's bun.exe is running.
- // `ocx update` handles this itself; the printed instruction has to match what it does.
- const diagnostic = collectBundledBunDiagnostic({
- platform: "win32",
- bundledPath: () => null,
- detectOwnership: () => ({ installer: "npm" }),
- });
- const lines = formatBundledBunDiagnosticLines(diagnostic).join("\n");
-
- expect(lines).toContain("the `bun` dependency is not installed");
- expect(lines.indexOf("ocx stop")).toBeLessThan(lines.indexOf("npm install -g --allow-scripts=bun"));
- expect(lines).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
- expect(lines).toContain("ocx service restart");
- });
-});
diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts
index 962a42405f7..fe4780a2aa4 100644
--- a/tests/update/update-job.test.ts
+++ b/tests/update/update-job.test.ts
@@ -1836,8 +1836,7 @@ describe("GUI update execution decisions", () => {
describe("immutable update target (WP160)", () => {
test("a resolved version pins the install target instead of the movable tag", () => {
expect(updateCommand("bun", "latest", "2.7.24").args).toEqual(["add", "-g", "@bitkyc08/opencodex@2.7.24"]);
- // npm 12 blocks dependency lifecycle scripts by default; see update-npm-allow-scripts.test.ts.
- expect(updateCommand("npm", "latest", "2.7.24").args).toEqual(["install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@2.7.24"]);
+ expect(updateCommand("npm", "latest", "2.7.24").args).toEqual(["install", "-g", "@bitkyc08/opencodex@2.7.24"]);
expect(updateCommandStr("bun", "latest", "2.7.24")).toContain("@bitkyc08/opencodex@2.7.24");
// Unknown version falls back to the tag (best-effort lane).
expect(updateCommand("bun", "latest").args).toEqual(["add", "-g", "@bitkyc08/opencodex@latest"]);
diff --git a/tests/update/update-npm-allow-scripts.test.ts b/tests/update/update-npm-allow-scripts.test.ts
deleted file mode 100644
index 11d577234a1..00000000000
--- a/tests/update/update-npm-allow-scripts.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { readFileSync } from "node:fs";
-import { updateCommand, updateCommandStr } from "../../src/update/index";
-import { repoPath } from "../helpers/repo-root";
-
-/**
- * Issue #8361: npm 12 blocks dependency lifecycle scripts by default, so an `ocx update` that
- * ran a bare `npm install -g` removed the old files and installed a package whose bundled `bun`
- * dependency never unpacked. The next `ocx` then failed with "the `bun` dependency is not
- * installed". Every path that builds an npm install argument must approve exactly that one script.
- *
- * The flag is not conditional on the npm major: npm 8 through 11 all install with it on a global
- * install (some npm 11 releases warn that it is unknown to the CLI config, then install).
- * A version gate would add a failure mode that depends on which npm happened to be first on PATH.
- */
-describe("npm update allows bun's postinstall (#8361)", () => {
- test("the update command passes --allow-scripts=bun before the package spec", () => {
- expect(updateCommand("npm", "latest", "2.7.24")).toEqual({
- bin: "npm",
- args: ["install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@2.7.24"],
- });
- // The tag fallback (registry resolution unavailable) keeps the same approval.
- expect(updateCommand("npm", "preview", null).args).toEqual([
- "install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@preview",
- ]);
- expect(updateCommandStr("npm", "latest", "2.7.24"))
- .toBe("npm install -g --allow-scripts=bun @bitkyc08/opencodex@2.7.24");
- });
-
- test("the other managers keep their own build approval instead of npm's flag", () => {
- expect(updateCommand("pnpm", "latest", "2.7.24").args).toEqual([
- "add", "-g", "--allow-build=bun", "@bitkyc08/opencodex@2.7.24",
- ]);
- expect(updateCommand("bun", "latest", "2.7.24").args).toEqual([
- "add", "-g", "@bitkyc08/opencodex@2.7.24",
- ]);
- });
-
- test("the Node launcher's own npm reinstall carries the same approval", () => {
- // The launcher is plain Node and runs before this module loads, so it repeats the argv.
- // Source-pinned because loading it executes the CLI.
- const launcher = readFileSync(repoPath("bin", "ocx.mjs"), "utf8");
- expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]');
- // The manual-update failure hint and the fatal runtime message name the same command.
- expect(launcher).toContain('"npm install -g --allow-scripts=bun @bitkyc08/opencodex"');
- expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${latest || tag}`]');
- });
-});
From 35055fb6807f7070e52a694988cd7bbb2004c665 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 08/14] Revert "fix(copilot): keep Auto negotiation off real
DNS in tests and survive a mid-negotiation account switch (#8389)"
This reverts commit 1176557e31e0ca4750768f9bed53213c929fa94b.
---
src/providers/github-copilot-auto.ts | 30 +++-------------
.../github-copilot-account-origin.test.ts | 36 ++-----------------
.../github-copilot-auto.test.ts | 28 ++-------------
3 files changed, 9 insertions(+), 85 deletions(-)
diff --git a/src/providers/github-copilot-auto.ts b/src/providers/github-copilot-auto.ts
index e42347342be..29797761aa6 100644
--- a/src/providers/github-copilot-auto.ts
+++ b/src/providers/github-copilot-auto.ts
@@ -130,32 +130,15 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM
let models: CopilotModel[];
try { models = await fetchCopilotAutoModels(transport, signal, beforeSend, 60_000, concurrency); }
catch (error) {
- if (signal?.aborted) throw error;
- // A manual account switch makes `beforeSend` return false, which cancels this
- // control-plane send. That is not a client abort: fall back to the requested model
- // so the dispatch path revalidates the selection and rebuilds on the new account.
- if (error instanceof ProviderOutboundSendCancelledError) {
- return { provider: transport, modelId: requestedModel, auto: false };
- }
+ if (signal?.aborted || error instanceof ProviderOutboundSendCancelledError) throw error;
// Permission discovery is additive for legacy paid configurations. A temporary
// discovery outage must not turn a previously usable named route into a refusal.
return { provider: transport, modelId: requestedModel, auto: false };
}
if (!copilotRequiresAuto(provider, models))
return { provider: transport, modelId: requestedModel, auto: false };
- // The same authority fence guards the later control-plane sends: a manual switch that
- // lands during the session or intent step falls back too, so dispatch rebuilds on the
- // new account instead of failing the whole negotiation. A client abort still rejects.
- const fallBackOnSwitch = (error: unknown) => {
- if (!signal?.aborted && error instanceof ProviderOutboundSendCancelledError)
- return { provider: transport, modelId: requestedModel, auto: false } as const;
- throw error;
- };
- let session: Awaited>;
- try {
- session = await requestJson(transport, "/models/session", signal,
- { auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency);
- } catch (error) { return fallBackOnSwitch(error); }
+ const session = await requestJson(transport, "/models/session", signal,
+ { auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency);
const pool = strings(session.available_models);
if (typeof session.session_token !== "string" || !session.session_token || session.session_token.length > 32_768 || !/^[\x21-\x7e]+$/.test(session.session_token) || !pool.length)
throw new Error("GitHub Copilot Auto session omitted its routing credentials");
@@ -170,11 +153,8 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM
? typeof lastUser.content === "string" ? lastUser.content : lastUser.content.flatMap(/** Extract text parts for intent negotiation without forwarding image or other multimodal payloads. */
part => part.type === "text" ? [part.text] : []).join("\n")
: "";
- let intent: Awaited>;
- try {
- intent = await requestJson(transport, "/models/session/intent", signal,
- { prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency);
- } catch (error) { return fallBackOnSwitch(error); }
+ const intent = await requestJson(transport, "/models/session/intent", signal,
+ { prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency);
const modelId = strings(intent.candidate_models).find(/** Choose the first server-ranked candidate that belongs to this credential-bound session pool. */
candidate => pool.includes(candidate));
if (!modelId) throw new Error("GitHub Copilot Auto intent returned no eligible model");
diff --git a/tests/providers/github-copilot/github-copilot-account-origin.test.ts b/tests/providers/github-copilot/github-copilot-account-origin.test.ts
index db62fb00594..e5d704cd9ed 100644
--- a/tests/providers/github-copilot/github-copilot-account-origin.test.ts
+++ b/tests/providers/github-copilot/github-copilot-account-origin.test.ts
@@ -7,11 +7,9 @@ import { getAccountSet, saveCredential as persistCredential, setActiveAccount as
import { handleResponses as dispatchResponses } from "../../../src/server/responses";
import { saveConfig } from "../../../src/config";
import { setActiveProviderApiKey } from "../../../src/providers/api-keys";
-import { clearCopilotAutoModelsForTests } from "../../../src/providers/github-copilot-auto";
import type { OcxConfig } from "../../../src/types";
import { removeTreeWithRetry } from "../../helpers/remove-tree";
import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home";
-import { stubbedProviderFetch } from "../../helpers/catalog-provider-fetch";
const ACCOUNT_A_ORIGIN = "https://a.githubcopilot.com";
const ACCOUNT_B_ORIGIN = "https://b.githubcopilot.com";
@@ -138,9 +136,6 @@ function config(wire: Wire): OcxConfig {
adapter: "openai-chat",
authMode: "oauth",
baseUrl: "https://api.githubcopilot.com",
- // Copilot Auto negotiates /models on the pinned outbound transport. Give it the
- // fixture executor so the negotiation never resolves a real peer address.
- fetch: stubbedProviderFetch,
models: [model],
...(wire === "responses" ? { modelAdapters: { [model]: "openai-responses" } } : {}),
},
@@ -214,9 +209,8 @@ function installFetch(options: {
switchToAccountId?: string;
switchOn: "refresh" | "first-dispatch" | "never";
emptyFirst?: boolean;
-}): { dispatches: { origin: string; authorization: string }[]; modelsRequests: { origin: string }[] } {
+}): { dispatches: { origin: string; authorization: string }[] } {
const dispatches: { origin: string; authorization: string }[] = [];
- const modelsRequests: { origin: string }[] = [];
let refreshSwitched = false;
globalThis.fetch = (async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
@@ -234,13 +228,6 @@ function installFetch(options: {
if (url === GITHUB_USER_URL) return Response.json({ id: 101 });
const parsed = new URL(url);
- if (parsed.pathname === "/models") {
- modelsRequests.push({ origin: parsed.origin });
- return Response.json({ data: [
- { id: "gpt-4o", model_picker_enabled: true },
- { id: "gpt-5.4", model_picker_enabled: true },
- ] });
- }
if (parsed.hostname.endsWith(".githubcopilot.com") || parsed.hostname === "api.githubcopilot.com") {
dispatches.push({
origin: parsed.origin,
@@ -268,7 +255,7 @@ function installFetch(options: {
}
return originalFetch(input, init);
}) as typeof fetch;
- return { dispatches, modelsRequests };
+ return { dispatches };
}
beforeEach(() => {
@@ -288,7 +275,6 @@ beforeEach(() => {
));
process.env.OPENCODEX_HOME = home;
clearGenericFailoverHealth();
- clearCopilotAutoModelsForTests();
// Dispatches without starting a server, so it takes the spend-journal lease itself. Taken
// last because the lease binds the home in effect at the moment it is taken.
releaseSpendHome = acquireOwnedSpendHome();
@@ -464,12 +450,6 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.hostname === "api.exa.ai") return Response.json({ results: [] });
if (!url.hostname.endsWith(".githubcopilot.com")) throw new Error("Unexpected fixture request");
- if (url.pathname === "/models") {
- return Response.json({ data: [
- { id: "gpt-4o", model_picker_enabled: true },
- { id: "gpt-5.4", model_picker_enabled: true },
- ] });
- }
sent.push(new Headers(init?.headers).get("authorization") ?? "");
bodies.push(JSON.parse(String(init?.body)));
const delta = sent.length === 1
@@ -500,18 +480,6 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => {
expect(sends).toBe(0);
});
- test("Copilot Auto model negotiation is served by the fixture fetch, never real DNS", async () => {
- await seedAccounts();
- const observed = installFetch({ wire: "chat", statuses: [200], switchOn: "never" });
- const response = await handleResponses(request("chat"), config("chat"), { model: "", provider: "" });
- await responseText(response);
- expect(response.status).toBe(200);
- // The negotiation runs on providerOutboundGet, which resolves and pins a real peer
- // address unless the provider hands it an executor. This fixture must see the request.
- expect(observed.modelsRequests).toEqual([{ origin: ACCOUNT_A_ORIGIN }]);
- expect(observed.dispatches).toEqual([{ origin: ACCOUNT_A_ORIGIN, authorization: bearer("copilot-access-a") }]);
- });
-
for (const wire of ["chat", "responses"] as const) {
test(`${wire} revalidates selection after pacing and before physical dispatch`, async () => {
const accounts = await seedAccounts();
diff --git a/tests/providers/github-copilot/github-copilot-auto.test.ts b/tests/providers/github-copilot/github-copilot-auto.test.ts
index ef9cf814699..625f2f1c17a 100644
--- a/tests/providers/github-copilot/github-copilot-auto.test.ts
+++ b/tests/providers/github-copilot/github-copilot-auto.test.ts
@@ -198,37 +198,13 @@ describe("Copilot Auto permissions and negotiation", () => {
expect((await resolveCopilotAuto(provider, "named", parsed)).modelId).toBe("named");
expect(calls).toBe(1);
});
- for (const cancelAt of ["/models/session", "/models/session/intent"] as const) {
- test(`an authority change during ${cancelAt} also falls back instead of failing negotiation`, async () => {
- const seen: string[] = [];
- let allowed = true;
- const provider = { authMode: "oauth", baseUrl: "https://api.githubcopilot.com", fetch: (async (url: string | URL | Request) => {
- const path = new URL(String(url instanceof Request ? url.url : url)).pathname;
- seen.push(path);
- if (path.endsWith("/models")) return Response.json({ data: [{ id: "eligible", model_picker_enabled: false, supported_endpoints: ["/responses"] }] });
- if (path.endsWith("/models/session")) return Response.json({ session_token: "private-session", available_models: ["eligible"], expires_at: Math.floor(Date.now() / 1000) + 3600 });
- return Response.json({ candidate_models: ["eligible"] });
- }) as typeof fetch } as OcxProviderConfig;
- // The switch lands just before the named control-plane send.
- const beforeSend = () => {
- if (seen.length === (cancelAt === "/models/session" ? 1 : 2)) allowed = false;
- return allowed;
- };
- await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, beforeSend))
- .resolves.toMatchObject({ modelId: "auto", auto: false });
- expect(seen.some(path => path.endsWith(cancelAt))).toBe(false);
- });
- }
- test("cancellation aborts, while an authority change falls back without a negotiation send", async () => {
+ test("cancellation and authority guard prevent negotiation sends", async () => {
let sends = 0;
const provider = { authMode: "oauth", baseUrl: "https://api.githubcopilot.com", fetch: (async () => {
sends++; return Response.json({});
}) as typeof fetch } as OcxProviderConfig;
await expect(resolveCopilotAuto(provider, "auto", parsed, AbortSignal.abort())).rejects.toThrow();
- // A manual account switch cancels the queued control-plane send. That is not a client
- // abort: the caller revalidates the selection and rebuilds before dispatch.
- await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, () => false))
- .resolves.toMatchObject({ modelId: "auto", auto: false });
+ await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, () => false)).rejects.toThrow();
expect(sends).toBe(0);
});
});
From 9c74cf85d48c0c894c171ace9b8e4bcced9d7f68 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:36 +0900
Subject: [PATCH 09/14] Revert "fix(link): show the Child's own reason when ocx
link add fails (#8390)"
This reverts commit d5034499e5c39212c5f2304fb3aa92e5d2fe8479.
---
.../src/content/docs/guides/remote-link.md | 2 +-
src/cli/link-enrollment.ts | 5 +-
src/cli/link.ts | 12 +---
src/server/management/link-child-preflight.ts | 55 +------------------
src/server/management/link-enrollment.ts | 7 +--
structure/remote-link.md | 2 +-
tests/cli/cli-link-enrollment.test.ts | 27 ---------
tests/server/link-enrollment.test.ts | 33 +----------
8 files changed, 10 insertions(+), 133 deletions(-)
diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md
index ebc6f881b5b..65e7f7b7965 100644
--- a/docs-site/src/content/docs/guides/remote-link.md
+++ b/docs-site/src/content/docs/guides/remote-link.md
@@ -234,7 +234,7 @@ The check covers the intent folder and record. It does not protect against repla
On the Home, run `ocx link probe child-a --json` to inspect the offered SSH fingerprint, key type and OpenCodex version. Verify the fingerprint through a trusted source. Then run `ocx link add child-a --expect-fingerprint SHA256:… --json`. The command probes again and refuses a mismatch before storing trust or issuing a key. Without the fingerprint flag, it requires an interactive terminal and an explicit `yes` after showing the fingerprint. It never prints the link key.
-Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port and expose bound restart support. POSIX Children need curl; Windows uses a bounded loopback HTTP request through .NET. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows x64 Children use encoded PowerShell commands without consuming key stdin. The preflight reads `ocx config show --json` under the runner's 4 MiB capture limit, so a Child with a large saved configuration still accepts a link; output past that limit answers `child_preflight_output_limit` and names the command to trim. When a phase fails, `ocx link add` prints the phase together with the Child's own reason, for example an invalid saved configuration or the failing command's last error line. That reason is shortened and has OpenCodex keys and URL query strings removed.
+Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port and expose bound restart support. POSIX Children need curl; Windows uses a bounded loopback HTTP request through .NET. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows x64 Children use encoded PowerShell commands without consuming key stdin. The preflight reads `ocx config show --json` under the runner's 4 MiB capture limit, so a Child with a large saved configuration still accepts a link; output past that limit answers `child_preflight_output_limit` and names the command to trim.
### Ephemeral Child machines (`ocx-child`)
diff --git a/src/cli/link-enrollment.ts b/src/cli/link-enrollment.ts
index f459c68ba47..adee88bfbae 100644
--- a/src/cli/link-enrollment.ts
+++ b/src/cli/link-enrollment.ts
@@ -207,10 +207,7 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[]
if (code === "child_preflight_output_limit") throw new Error("The Child's ocx config show --json output exceeded the Remote Link preflight limit; trim the Child's saved configuration and retry.");
if (code === "child_external_provider_conflict") throw new Error("The Child's ~/.codex/config.toml is already managed by another model provider or custom base URL; no Child files were modified.");
const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined;
- // The server's hint is the Child's own bounded, redacted reason (#8357); a phase name
- // alone left every failure to SSH reproduction and source reading.
- const hint = record(error.body.error) && typeof error.body.error.hint === "string" ? error.body.error.hint : "";
- const message = phase ? "Remote Link failed during " + phase + (hint ? ": " + hint : "") : error.message;
+ const message = phase ? "Remote Link failed during " + phase : error.message;
const recovery = ephemeralFlow ? RECOVERY_EPHEMERAL : RECOVERY_FULL;
if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + recovery : ""));
}
diff --git a/src/cli/link.ts b/src/cli/link.ts
index 4ddc805d9fe..a35921f6856 100644
--- a/src/cli/link.ts
+++ b/src/cli/link.ts
@@ -12,7 +12,6 @@ import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility";
import { runLinkEnrollment } from "./link-enrollment";
import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup";
import { assertSshAlias } from "../link/ssh-argv";
-import { boundHint } from "../link/ssh-runner";
import { linkStorePath } from "../link/paths";
import { isLinkPort } from "../link/ports";
import { readLinkStore, type LinkDirection, type LinkStore } from "../link/store";
@@ -248,22 +247,17 @@ async function linkRequest(path: string, init: RequestInit, deps: LinkCliDeps
} catch (error) {
if (error instanceof RuntimeApiError) {
// Never echo an API error body: issue responses contain a one-time data key. Only the error
- // code, the server's already-bounded hint and the residual flag survive (errorCodeBody).
+ // code survives, because callers branch on it and a code is never secret.
throw new RuntimeApiError(`Link management request failed (${error.status})`, error.status, errorCodeBody(error.body));
}
throw error;
}
}
-function errorCodeBody(body: unknown): { error: { code: string; hint?: string }; residual?: true } | null {
+function errorCodeBody(body: unknown): { error: { code: string }; residual?: true } | null {
if (!isRecord(body) || !isRecord(body.error)) return null;
const code = body.error.code;
- if (typeof code !== "string" || !/^[a-z_]{1,64}$/.test(code)) return null;
- // The hint is the only other field kept (#8357): the server builds it from a Child's last stderr
- // line, already bounded and redacted, and it is bounded and redacted again here. The rest of
- // the body is still never echoed.
- const hint = typeof body.error.hint === "string" ? boundHint(body.error.hint) : undefined;
- return { error: { code, ...(hint ? { hint } : {}) }, ...(body.residual === true ? { residual: true as const } : {}) };
+ return typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? { error: { code }, ...(body.residual === true ? { residual: true as const } : {}) } : null;
}
async function runPort(args: string[], deps: LinkCliDeps): Promise {
diff --git a/src/server/management/link-child-preflight.ts b/src/server/management/link-child-preflight.ts
index d3332f03dee..6bcedc8ce90 100644
--- a/src/server/management/link-child-preflight.ts
+++ b/src/server/management/link-child-preflight.ts
@@ -1,4 +1,4 @@
-import { SSH_MAX_OUTPUT_BYTES, boundHint, sshFailureHint } from "../../link/ssh-runner";
+import { SSH_MAX_OUTPUT_BYTES } from "../../link/ssh-runner";
/**
* Home enrollment reads one Child command before it may restart that Child: `ocx config show
@@ -19,56 +19,3 @@ export const CHILD_CONFIG_TOO_LARGE_MESSAGE = "The Child configuration is too la
/** The operator-facing hint for a Child whose config output exceeded even the raised cap. */
export const CHILD_CONFIG_TOO_LARGE_HINT =
`ocx config show --json on the Child exceeded ${CHILD_PREFLIGHT_MAX_OUTPUT_BYTES} bytes; trim the Child's saved configuration and retry`;
-
-/**
- * Why the Child's effective configuration could not be read (#8357): a failed `config show`
- * names its own last stderr line (bounded and redacted by `sshFailureHint`, e.g. an invalid
- * saved configuration), and a successful one that printed no usable JSON says so.
- */
-export function childConfigFailureHint(result: { code: number; stderr: string }): string {
- if (result.code !== 0) return sshFailureHint(result.stderr) ?? `ocx config show --json exited ${result.code} on the Child`;
- return "ocx config show --json on the Child did not print a configuration with a valid port";
-}
-
-/** `ocx config validate --json` prints one small object; this bounds a misbehaving Child. */
-export const CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES = 16 * 1024;
-
-/**
- * The validation reason a full `ocx` Child gives for its own saved configuration, or undefined.
- *
- * `config show` on an invalid configuration prints only a generic warning whose path can push
- * the pointer past the 160-code-point hint cap, so it never names the defect (#8357). `config
- * validate --json` answers `{ "ok": false, "error": "schema_invalid: : " }`, naming
- * fields and expected types, never configured values. Only that `error` string is read from
- * stdout, and `boundHint` still redacts and caps it. A Child without the command (`ocx-child`,
- * an older `ocx`) prints nothing usable and the caller keeps the `config show` reason.
- */
-export function childConfigValidationHint(result: { code: number; stdout: string }): string | undefined {
- if (result.code === 0) return undefined;
- let parsed: unknown;
- try { parsed = JSON.parse(result.stdout); } catch { return undefined; }
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
- const { ok, error } = parsed as { ok?: unknown; error?: unknown };
- if (ok !== false || typeof error !== "string") return undefined;
- return boundHint(`Child config is invalid: ${error}`);
-}
-
-/**
- * The hint for an unreadable Child configuration: the Child's own validation reason when `config
- * show` failed and `config validate` can name it, otherwise `childConfigFailureHint`. The
- * extra command runs only on this failure path and is read-only on the Child.
- */
-export async function childConfigFailureReason(
- configured: { code: number; stderr: string },
- validate: () => Promise<{ code: number; stdout: string }>,
-): Promise {
- if (configured.code !== 0) {
- try {
- const reason = childConfigValidationHint(await validate());
- if (reason) return reason;
- } catch {
- // A timeout or transport failure here must not mask the config show reason.
- }
- }
- return childConfigFailureHint(configured);
-}
diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts
index 0d58e8c3c2c..c833c1a7ffd 100644
--- a/src/server/management/link-enrollment.ts
+++ b/src/server/management/link-enrollment.ts
@@ -15,7 +15,7 @@ import { withConfigMutationLockSync } from "../../config/mutation-lock";
import type { ManagementContext } from "./context";
import { readManagementJsonBodyOr } from "./body";
import { fail, exactBody, port, isRecord, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, putKnownHost, issueKey, compensateNewLink, type LinkRouteState } from "./link-route-state";
-import { CHILD_CONFIG_TOO_LARGE_HINT, CHILD_CONFIG_TOO_LARGE_MESSAGE, CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES, childConfigFailureReason } from "./link-child-preflight";
+import { CHILD_CONFIG_TOO_LARGE_HINT, CHILD_CONFIG_TOO_LARGE_MESSAGE, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES } from "./link-child-preflight";
const PROBE_TTL_MS = 5 * 60_000;
const MIN_REMOTE_OCX = [2, 66, 0] as const;
@@ -290,10 +290,7 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom
const configured = await run(ocx(["config", "show", "--json"]), budget, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES);
let childConfig: unknown = null;
try { if (configured.code === 0) childConfig = JSON.parse(configured.stdout); } catch { /* fail closed */ }
- if (!isRecord(childConfig) || !port(childConfig.port)) {
- const hint = await childConfigFailureReason(configured, () => run(ocx(["config", "validate", "--json"]), budget, CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES));
- return await failed(fail("child_preflight_failed", "The Child effective configuration could not be read.", 502, hint));
- }
+ if (!isRecord(childConfig) || !port(childConfig.port)) return await failed(fail("child_preflight_failed", "The Child effective configuration could not be read.", 502));
if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return await failed(fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409));
const childPort = childConfig.port;
if (remotePlatform === "posix" && (await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return await failed(fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409));
diff --git a/structure/remote-link.md b/structure/remote-link.md
index 81b5608a86f..74f931f263a 100644
--- a/structure/remote-link.md
+++ b/structure/remote-link.md
@@ -154,7 +154,7 @@ A deadline still rejects late output.
`src/server/management/link-enrollment.ts` owns probe, host confirmation and Home apply; `src/server/management/link-route-state.ts` supplies shared authorization and lifecycle compensation. The three routes accept the existing dashboard authority or trusted loopback admin authority; Tailscale identity sessions remain refused. Join remains operator-paired only. CLI `ocx link probe ` requests the optional `includeVersion: true` offer; legacy dashboard offers retain their three fields. Version probing executes against the temporary learned key and persists no trust. Confirm rechecks the pending generation and five-minute expiry after its SSH await, then updates the latest known_hosts atomically under the config lock. Same-alias operations fail fast on overlap; apply pins a private immutable host file and consumes confirmation before issuing a key.
-`src/link/remote-probe.ts` separates command budget (15 seconds plus four times measured SSH setup, capped at 60 seconds) from a fresh 15-second relay deadline. Restart adds the existing 60-second drain and 70-second replacement windows. The runner's one deadline covers stdin writes, process exit and output drains; failure cancels readers and kills the local SSH child without an unbounded cleanup wait. Ordinary stdout and stderr cap at 64 KiB. The catalog command and the Child `config show --json` preflight in `src/server/management/link-child-preflight.ts` permit 4 MiB stdout, because no smaller command reports `port`, `runtimeRole` and `ephemeralChild` on every supported Child: full `ocx` has `config get`, the lightweight `ocx-child` binary answers only `config show --json`. A Child exceeding even that cap answers `502 child_preflight_output_limit` instead of a generic preflight failure. Coverage: `tests/clients/link-ssh-runner.test.ts`, `tests/server/link-enrollment.test.ts`. A `config show` that exits non-zero answers `child_preflight_failed` with the Child's own validation reason as the hint: a full `ocx` Child then runs `config validate --json` (16 KiB cap), and only its `error` string, which names fields and expected types and never configured values, is read through `boundHint`. A Child without that command, or one whose validate fails or times out, keeps the last `config show` stderr line (`sshFailureHint`), or names the exit code when stderr is empty; a `config show` that exits 0 without a usable port names the command. The CLI (`src/cli/link.ts` `errorCodeBody`) keeps exactly that hint, bounded and redacted again with `boundHint`, beside the error code, and still never echoes the rest of an error body (#8357).
+`src/link/remote-probe.ts` separates command budget (15 seconds plus four times measured SSH setup, capped at 60 seconds) from a fresh 15-second relay deadline. Restart adds the existing 60-second drain and 70-second replacement windows. The runner's one deadline covers stdin writes, process exit and output drains; failure cancels readers and kills the local SSH child without an unbounded cleanup wait. Ordinary stdout and stderr cap at 64 KiB. The catalog command and the Child `config show --json` preflight in `src/server/management/link-child-preflight.ts` permit 4 MiB stdout, because no smaller command reports `port`, `runtimeRole` and `ephemeralChild` on every supported Child: full `ocx` has `config get`, the lightweight `ocx-child` binary answers only `config show --json`. A Child exceeding even that cap answers `502 child_preflight_output_limit` instead of a generic preflight failure. Coverage: `tests/clients/link-ssh-runner.test.ts`, `tests/server/link-enrollment.test.ts`.
Before issuance, apply requires curl and effective standalone Child configuration plus a native Child health response on its configured port with `restartCapability: v1`. Already-client, mismatched-port and pre-capability runtimes are refused without enrollment. Thus a capable older standalone Child can be linked by a newer Home. Home calls existing `ocx restart` exactly once after `connect --key-stdin`; no raw stop/start or timeout replay. After client-role health, Home registers a fresh expected-key observer and asks Child's credential-free loopback `/v1/catalog`. Both a complete HTTP 200 JSON catalog and matching authenticated Home callback must occur within the relay window. Enrollment admission cannot satisfy this check. Curl ignores curlrc, proxies and redirects. Phase-specific failures compensate Home state; after possible remote commit, `residual: true` and fixed Child disconnect/restart guidance remain even if compensation fails. This is not remote rollback.
diff --git a/tests/cli/cli-link-enrollment.test.ts b/tests/cli/cli-link-enrollment.test.ts
index 13dd81b9832..5cc2cf21dfd 100644
--- a/tests/cli/cli-link-enrollment.test.ts
+++ b/tests/cli/cli-link-enrollment.test.ts
@@ -167,33 +167,6 @@ describe("CLI Remote Link enrollment", () => {
}
});
- test("a phase failure prints the server's bounded cause, not only the phase name (#8357)", async () => {
- let step = 0;
- const h = harness();
- try {
- const hint = "Error: defaultProvider must exist in providers";
- h.deps.fetchImpl = async () => ++step < 3 ? Response.json(step === 1 ? probe : confirm) : Response.json({ error: { code: "child_preflight_failed", message: "The Child effective configuration could not be read.", hint } }, { status: 502 });
- expect(await runLinkCommand(["add", "child-a", "--expect-fingerprint", fingerprint], h.deps)).not.toBe(0);
- const message = h.err.join("\n");
- expect(message).toContain("Remote Link failed during Child preflight");
- expect(message).toContain(hint);
- } finally { h.restore(); }
- });
-
- test("only the bounded, redacted hint survives from an error body (#8357)", async () => {
- let step = 0;
- const h = harness();
- try {
- const secret = "ocx_data_" + "a".repeat(40);
- h.deps.fetchImpl = async () => ++step < 3 ? Response.json(step === 1 ? probe : confirm)
- : Response.json({ error: { code: "remote_connect_failed", message: "leak " + secret, hint: "connect said " + secret }, key: secret }, { status: 502 });
- expect(await runLinkCommand(["add", "child-a", "--expect-fingerprint", fingerprint], h.deps)).not.toBe(0);
- const message = h.err.join("\n");
- expect(message).toContain("Remote Link failed during Child enrollment: connect said ocx_");
- expect(message).not.toContain(secret);
- } finally { h.restore(); }
- });
-
test("a bare host without a local ocx-child build names where Home looks for one", async () => {
const missing = { alias: "bare-b", fingerprint, keyType: "ED25519", ocxVersion: null, runtimeKind: "missing", targetTriple: "linux-arm64", plantable: false, plantUnavailableReason: "artifact_not_found" };
const h = harness([missing]);
diff --git a/tests/server/link-enrollment.test.ts b/tests/server/link-enrollment.test.ts
index 30b9e503cfc..7d834c7b34d 100644
--- a/tests/server/link-enrollment.test.ts
+++ b/tests/server/link-enrollment.test.ts
@@ -32,7 +32,7 @@ const result = (stdout = "", code = 0): SshRunResult => ({ code, stdout, stderr:
const http = (body: unknown, status = 200) => result(`${JSON.stringify(body)}\n${status}`);
const health = (role: string, restartCapability: string | null = "v1") => http({ service: "opencodex", pid: 4321, port: 10100, role, ...(restartCapability === null ? {} : { restartCapability }) });
type RunOptions = Parameters[1];
-type Step = "probe" | "uname" | "windows" | "fingerprint" | "version" | "port" | "config" | "validate" | "curl" | "before" | "connect" | "restart" | "health" | "catalog";
+type Step = "probe" | "uname" | "windows" | "fingerprint" | "version" | "port" | "config" | "curl" | "before" | "connect" | "restart" | "health" | "catalog";
interface Call { step: Step; argv: readonly string[]; options: RunOptions; }
function deferred() {
let resolve!: (value: T) => void;
@@ -92,7 +92,6 @@ function harness(remotePlatform: "posix" | "windows" = "posix") {
: command === remoteOcx(["--version"]) ? "version"
: command === remoteOcx(["link", "port"]) ? "port"
: command === remoteOcx(["config", "show", "--json"]) ? "config"
- : command === remoteOcx(["config", "validate", "--json"]) ? "validate"
: command.includes("ocx-curl-preflight") ? "curl"
: (nativeArgs?.[0] === "connect" || command.includes("'connect' '--link' '--key-stdin'")) ? "connect"
: command === remoteOcx(["restart"]) ? "restart"
@@ -124,7 +123,6 @@ function harness(remotePlatform: "posix" | "windows" = "posix") {
case "version": return result("opencodex 2.83.0\n");
case "port": return result('{"port":2200}');
case "config": return result('{"port":10100}\n');
- case "validate": return { code: 1, stdout: "", stderr: "Unknown command: config validate\n" };
case "curl": case "connect": return result();
case "before": return health("standalone");
case "restart": return result();
@@ -348,35 +346,6 @@ describe("Home enrollment of a standalone Child", () => {
expect(config.options?.maxOutputBytes).toBe(SSH_MAX_OUTPUT_BYTES);
});
- test("a Child preflight failure carries the Child's own reason (#8357)", async () => {
- // The line a real `ocx config show --json` prints for an invalid saved configuration: generic,
- // and long enough that the 160-code-point cap cuts it before naming any defect.
- const showWarning = "Warning: Config at /var/lib/opencodex-child/Library/Application Support/opencodex/config.json is invalid or unreadable; defaults are being shown for invalid settings. Run: ocx config validate. Inspect: ocx config show --source.\n";
- const h = harness(); h.confirmed();
- h.overrides.config = () => ({ code: 1, stdout: "", stderr: showWarning });
- h.overrides.validate = () => ({ code: 1, stdout: '{\n "ok": false,\n "error": "schema_invalid: defaultProvider: defaultProvider must exist in providers"\n}\n', stderr: "" });
- const payload = await denied(await h.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } };
- expect(payload.error.hint).toBe("Child config is invalid: schema_invalid: defaultProvider: defaultProvider must exist in providers");
- expect(h.calls.find(call => call.step === "validate")?.options?.maxOutputBytes).toBe(16 * 1024);
- noEnrollmentEffects(h);
- // A Child without `config validate` (ocx-child, an older ocx) keeps the config show reason.
- const legacy = harness(); legacy.confirmed();
- legacy.overrides.config = () => ({ code: 1, stdout: "", stderr: "opencodex: invalid config\nError: defaultProvider must exist in providers\n" });
- const third = await denied(await legacy.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } };
- expect(third.error.hint).toBe("Error: defaultProvider must exist in providers");
- // A validate that times out must not mask it either.
- const hung = harness(); hung.confirmed();
- hung.overrides.config = () => ({ code: 1, stdout: "", stderr: "Error: defaultProvider must exist in providers\n" });
- hung.overrides.validate = () => { throw new SshRunnerError("timeout", "ssh command timed out"); };
- const fourth = await denied(await hung.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } };
- expect(fourth.error.hint).toBe("Error: defaultProvider must exist in providers");
- const unreadable = harness(); unreadable.confirmed();
- unreadable.overrides.config = () => result("not json\n");
- const second = await denied(await unreadable.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } };
- expect(second.error.hint).toContain("config show --json");
- expect(unreadable.calls.some(call => call.step === "validate")).toBe(false);
- });
-
test("an output limit in Child preflight names the cap instead of failing generically", async () => {
const h = harness(); h.confirmed();
h.overrides.config = () => { throw new SshRunnerError("output_limit", `ssh output exceeded ${SSH_MAX_OUTPUT_BYTES} bytes`); };
From 659d767ceca7d1e169299cdc4bd9c4d28be7d399 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:37 +0900
Subject: [PATCH 10/14] Revert "feat(anthropic): add opt-in thinking display
policy (#8392)"
This reverts commit b684b46aa72c7ad5740b0ed601c9946f6a75bb91.
---
.../src/content/docs/reference/adapters.md | 5 -
.../docs/reference/configuration/providers.md | 2 -
scripts/test-layout/layout.json | 6 +-
src/adapters/anthropic.ts | 30 +---
src/config/provider-validation.ts | 29 ----
src/config/schema/config-schema.ts | 22 ---
src/config/schema/leaf-validators.ts | 5 -
src/providers/model-rename-fields.ts | 2 -
src/providers/resolved-model-policy.ts | 4 -
src/server/auth-cors.ts | 8 --
.../management/provider-overwrite-carry.ts | 11 +-
src/server/management/provider-routes.ts | 6 -
.../provider-thinking-display-patch.ts | 54 --------
src/types.ts | 2 -
src/types/provider.ts | 10 +-
src/types/wire.ts | 10 --
structure/providers/anthropic-progress.md | 6 -
.../anthropic-thinking-display-policy.test.ts | 125 -----------------
tests/config/config-thinking-display.test.ts | 56 --------
tests/fixtures/test-layout-expected.json | 3 -
...nagement-provider-thinking-display.test.ts | 128 ------------------
21 files changed, 8 insertions(+), 516 deletions(-)
delete mode 100644 src/server/management/provider-thinking-display-patch.ts
delete mode 100644 tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts
delete mode 100644 tests/config/config-thinking-display.test.ts
delete mode 100644 tests/server/management-provider-thinking-display.test.ts
diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md
index 528b71842c8..26ecb707983 100644
--- a/docs-site/src/content/docs/reference/adapters.md
+++ b/docs-site/src/content/docs/reference/adapters.md
@@ -395,11 +395,6 @@ MiMo model Command Code serves.
(`reasoning.summary: "none"`) keeps the provider default. Native Opus 5.5 on `api.anthropic.com`
defaults to `display: "updates"`; explicit `reasoning.summary: "concise"` or `"detailed"`
selects `"summarized"` without the updates beta. See [Opus 5.5 progress updates](/guides/providers/#claude-opus-55-progress-updates).
- The provider or exact-model `thinkingDisplay` setting overrides that default:
- `summarized` forces `display: "summarized"`, `updates` keeps progress commentary, and
- `omitted` drops the display field so a Chat client receives no reasoning deltas during the
- think. An explicit request summary still wins. See
- [provider configuration](/reference/configuration/providers/).
- **Structured output:** Responses `text.format` and Chat Completions `response_format` requests
with `type: "json_schema"` become Anthropic `output_config.format`. The format merges into an
existing adaptive-thinking output configuration, preserving a compatible `output_config.effort`.
diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md
index facaaaa2011..d9cca538c98 100644
--- a/docs-site/src/content/docs/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/reference/configuration/providers.md
@@ -388,8 +388,6 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity
| `modelSuppressSyntheticMax?` | `Record` | Catalog-only per-model switch. `true` prevents OpenCodex from adding a missing synthetic `max` rung while retaining a provider-declared `max` and continuing to add `ultra` to reasoning-capable ladders. A preserved row that already contains `max` keeps it until healthy discovery rebuilds that row, because the catalog does not persist whether an older `max` was synthetic. If a configured default names a suppressed missing `max`, the catalog falls back to the highest real rung below it. Codex uses the same ladder membership for the picker and explicit `spawn_agent` effort validation, so an explicit `max` spawn can fail client-side before proxy clamping; `ultra` remains the supported harness path. |
| `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. |
| `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. |
-| `thinkingDisplay?` | `"auto" \| "summarized" \| "updates" \| "omitted"` | Anthropic adaptive `thinking.display` policy for this provider. Unset and `auto` keep the built-in selection: native Claude Opus 5.5 on `api.anthropic.com` sends `updates` (progress commentary), and other adaptive models send `summarized`. `summarized` forces reasoning summaries for both. `updates` is honored only where the adapter already sent it — native Opus 5.5 — and is treated as `auto` for every other model. `omitted` sends adaptive thinking without a display field, matching hidden thinking; the stream then carries signature-only thinking blocks, so a Chat client sees only stream heartbeats during a long think. An explicit request `reasoning.summary` of `concise` or `detailed` still forces `summarized`. The dashboard provider editor and `PATCH /api/providers` accept `null` to clear it. `ocx config set providers..thinkingDisplay summarized` writes it directly. |
-| `modelThinkingDisplay?` | `Record` | Per-model override of `thinkingDisplay`, keyed by the exact upstream model id (for example `claude-opus-5-5`). An entry always wins over the provider-wide value, including an explicit `auto` that returns one model to the built-in behavior; a model with no entry uses the provider value. The same value rules apply, including `updates` degrading to `auto` on models that never received that display. `PATCH /api/providers` merges entries and accepts `null` for one entry or the whole map to clear it. |
| `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; Alibaba Token Plan (Beijing) pins `qwen3.8-flash`, `qwen3.7-plus`, and `glm-5.3` to its native Responses API for Responses inbound only (live-verified on that gateway, including plaintext reasoning replay; Chat and Anthropic inbound stay on Chat, and the rest of the family can be opted in here); and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. |
| xAI Chat Completions (dashboard / CLI) | switch | Grok 4.5/4.6 OAuth Responses requests default to Responses. Existing Chat overrides are migrated once on upgrade; later Chat choices are preserved. Turn on to select Chat for both models, off to select Responses. CLI: `ocx provider edit xai --xai-chat on` or `--xai-chat off` (running proxy required). Mixed means only one model currently uses Chat. Other overrides and tier policy stay unchanged. API-key and translated Chat/Anthropic defaults are unchanged. Grok 4.7 defaults to Responses on OAuth through its registry wire default and can use Chat through an explicit `modelAdapters["grok-4.7"] = "openai-chat"` override. |
| `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. |
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 544dcd10adb..d9a7b164e61 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -202,7 +202,7 @@
"anthropic-empty-content.test.ts": "adapters/anthropic",
"anthropic-eof-tolerance.test.ts": "adapters/anthropic",
"anthropic-error-body.test.ts": "adapters/anthropic",
- "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-thinking-display-policy.test.ts": "adapters/anthropic",
+ "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic",
"anthropic-fast-opt-in.test.ts": "adapters/anthropic",
"anthropic-fast-speed.test.ts": "adapters/anthropic",
"anthropic-hardening.test.ts": "adapters/anthropic",
@@ -777,7 +777,7 @@
"config-non-object-backup.test.ts": "config",
"config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config",
"config-rebase-provenance-writers.test.ts": "config",
- "config-save-boundary.test.ts": "config", "config-thinking-display.test.ts": "config",
+ "config-save-boundary.test.ts": "config",
"config-spend-ceilings.test.ts": "config",
"config-spend-pool-collision.test.ts": "config",
"config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config",
@@ -1217,7 +1217,7 @@
"management-provider-reset-replay.test.ts": "server",
"management-provider-synthetic-max.test.ts": "server",
"management-provider-upstream-websocket.test.ts": "server",
- "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-thinking-display.test.ts": "server",
+ "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server",
"management-provider-verbosity.test.ts": "server",
"management-route-registry.test.ts": "server",
"protocol-routes.test.ts": "server",
diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts
index 87a4071f22d..f864bd06468 100644
--- a/src/adapters/anthropic.ts
+++ b/src/adapters/anthropic.ts
@@ -1109,18 +1109,6 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
const summary = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning?.summary;
// Codex sends "auto" by default, so only an explicit concise/detailed request opts out of updates (#7728).
const summaryRequested = summary === "concise" || summary === "detailed";
- // Operator policy for the adaptive `thinking.display` field (#8363). Unset and "auto" keep
- // the built-in selection. Any model entry wins over the provider-wide value, including an
- // explicit "auto" that opts one model back out. "updates" is only sendable where this
- // adapter already sent it (native Opus 5.5), so anywhere else it is treated as auto rather
- // than inventing a display value the model never received.
- const modelThinkingDisplay = modelRecordValue(provider.modelThinkingDisplay, parsed.modelId);
- const configuredThinkingDisplay = modelThinkingDisplay !== undefined
- ? modelThinkingDisplay
- : provider.thinkingDisplay;
- const thinkingDisplayPolicy = configuredThinkingDisplay === "updates" && !nativeOpus55
- ? "auto"
- : configuredThinkingDisplay ?? "auto";
const thinkingToggleWire = declaresThinkingToggleWire(provider, parsed.modelId);
const reasoningControlsUnsupported = reasoningControlsAreUnsupported(provider, parsed.modelId);
const capabilities = resolveReasoningCapabilities(provider, parsed.modelId);
@@ -1157,16 +1145,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
// thinking blocks, so a Chat client sees minutes of heartbeats and no reasoning delta
// during a long think (#5824). Native Opus 5.5 selects its display below;
// other models request summarized thinking unless the caller hides it.
- // Under the default policy this stays the pre-existing selection, including its local
- // hideThinkingSummary behavior; a configured "summarized" overrides that hiding.
- // An explicit request summary forces summarized on every adaptive model (#7881), so it
- // also outranks a configured "omitted". "updates" already degraded to auto above
- // because only native Opus 5.5 can send it.
- // Local hideThinkingSummary only speaks for the built-in policy, where it keeps its
- // pre-existing meaning; an operator choice replaces that local preference.
- const hiddenThinkingDisplay = (thinkingDisplayPolicy === "omitted" && !summaryRequested)
- || (thinkingDisplayPolicy === "auto" && parsed.options.hideThinkingSummary);
- body.thinking = hiddenThinkingDisplay
+ body.thinking = parsed.options.hideThinkingSummary
? { type: "adaptive" }
: { type: "adaptive", display: "summarized" };
const effort = adaptiveEffort(effectiveReasoning);
@@ -1214,12 +1193,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
}
if (nativeOpus55 && !reasoningControlsUnsupported && thinkingEnabled !== false) {
- // An explicit per-request summary (concise/detailed) still forces summarized (#7881).
- body.thinking = summaryRequested || thinkingDisplayPolicy === "summarized"
- ? { type: "adaptive", display: "summarized" }
- : thinkingDisplayPolicy === "omitted"
- ? { type: "adaptive" }
- : { type: "adaptive", display: "updates" };
+ body.thinking = { type: "adaptive", display: summaryRequested ? "summarized" : "updates" };
}
// One discriminator governs the display beta and response classification, including reuse.
progressUpdates = (body.thinking as { display?: string } | undefined)?.display === "updates";
diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts
index 1ba0b7decbc..e06104c0709 100644
--- a/src/config/provider-validation.ts
+++ b/src/config/provider-validation.ts
@@ -13,7 +13,6 @@ import {
isWirePinnedModel,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
REASONING_SUMMARY_DELIVERY_VALUES,
- THINKING_DISPLAY_VALUES,
UPSTREAM_HTTP_VERSION_VALUES,
type OcxProviderConfig,
type ModelCapabilities,
@@ -30,7 +29,6 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([
"x-amz-security-token",
]);
const REASONING_SUMMARY_DELIVERY_SET = new Set(REASONING_SUMMARY_DELIVERY_VALUES);
-const THINKING_DISPLAY_SET = new Set(THINKING_DISPLAY_VALUES);
const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
const MAX_MODEL_DISPLAY_NAME_LENGTH = 128;
@@ -387,33 +385,6 @@ export function reasoningSummaryDeliveryRecordConfigError(
return null;
}
-/** Validate `thinkingDisplay`: one closed vocabulary shared by config and management writes. */
-export function thinkingDisplayConfigError(value: unknown, field = "thinkingDisplay"): string | null {
- if (value === undefined) return null;
- if (typeof value !== "string" || !THINKING_DISPLAY_SET.has(value)) {
- return `${field} must be one of: ${THINKING_DISPLAY_VALUES.join(", ")}`;
- }
- return null;
-}
-
-/** Validate `modelThinkingDisplay`: a plain map of model id to the same closed vocabulary. */
-export function modelThinkingDisplayConfigError(value: unknown, field = "modelThinkingDisplay"): string | null {
- if (value === undefined) return null;
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
- const prototype = Object.getPrototypeOf(value);
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
- for (const [key, entry] of Object.entries(value)) {
- const safeKey = JSON.stringify(redactSecretString(key));
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
- if (["__proto__", "prototype", "constructor"].includes(key)) {
- return `${field} keys must not be reserved object keys`;
- }
- const entryError = thinkingDisplayConfigError(entry, `${field}.${safeKey}`);
- if (entryError) return entryError;
- }
- return null;
-}
-
/** Validate a provider's per-model wire override map against runtime routing rules. */
export function modelAdapterRecordConfigError(
value: unknown,
diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts
index 3838c5a949e..b57c3efa537 100644
--- a/src/config/schema/config-schema.ts
+++ b/src/config/schema/config-schema.ts
@@ -60,8 +60,6 @@ import {
providerHeadersConfigError,
providerForwardClientHeadersConfigError,
reasoningSummaryDeliveryRecordConfigError,
- thinkingDisplayConfigError,
- modelThinkingDisplayConfigError,
credentialPinnedProviderConfigError,
} from "../provider-validation";
import {
@@ -769,26 +767,6 @@ export const configSchema = z.object({
message: reasoningSummaryDeliveryError,
});
}
- const thinkingDisplayError = thinkingDisplayConfigError(
- (provider as { thinkingDisplay?: unknown }).thinkingDisplay,
- );
- if (thinkingDisplayError) {
- ctx.addIssue({
- code: "custom",
- path: ["providers", redactSecretString(name), "thinkingDisplay"],
- message: thinkingDisplayError,
- });
- }
- const modelThinkingDisplayError = modelThinkingDisplayConfigError(
- (provider as { modelThinkingDisplay?: unknown }).modelThinkingDisplay,
- );
- if (modelThinkingDisplayError) {
- ctx.addIssue({
- code: "custom",
- path: ["providers", redactSecretString(name), "modelThinkingDisplay"],
- message: modelThinkingDisplayError,
- });
- }
const minMaxOutputError = positiveIntegerConfigError(
(provider as { minMaxOutputTokens?: unknown }).minMaxOutputTokens,
"minMaxOutputTokens",
diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts
index 594f910c11d..38a1a021c01 100644
--- a/src/config/schema/leaf-validators.ts
+++ b/src/config/schema/leaf-validators.ts
@@ -30,7 +30,6 @@ import {
MODEL_ADAPTER_OVERRIDE_ALLOWED,
pinnedWireAdapter,
PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
- THINKING_DISPLAY_VALUES,
UPSTREAM_HTTP_VERSION_VALUES,
type OcxProviderConfig,
type OcxConfig,
@@ -362,10 +361,6 @@ export const providerConfigSchema = z.object({
inferContextWindow: z.boolean().optional(),
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(),
- // Validated against a closed vocabulary here as well as at the write boundaries, so a
- // hand-edited typo cannot load silently and leave the operator with the built-in display (#8363).
- thinkingDisplay: z.enum(THINKING_DISPLAY_VALUES).optional(),
- modelThinkingDisplay: z.record(z.string().min(1), z.enum(THINKING_DISPLAY_VALUES)).optional(),
promoteAdditionalTools: z.boolean().optional(),
preserveResponsesReasoningContent: z.boolean().optional(),
preserveResponsesInputItemIds: z.boolean().optional(),
diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts
index 62dde304cd3..a97202d8bdc 100644
--- a/src/providers/model-rename-fields.ts
+++ b/src/providers/model-rename-fields.ts
@@ -103,8 +103,6 @@ export const PROVIDER_MODEL_RENAME_ROLES = {
modelSupportsVerbosity: "record",
supportsVerbosity: "none",
modelReasoningSummaryDelivery: "record",
- thinkingDisplay: "none",
- modelThinkingDisplay: "record",
modelPreferHostedTools: "record",
supportsOpenAiWebSearchToolFields: "none",
xaiResponsesXSearch: "none",
diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts
index fa3e6f6b29a..bacdb6dfb97 100644
--- a/src/providers/resolved-model-policy.ts
+++ b/src/providers/resolved-model-policy.ts
@@ -43,7 +43,6 @@ export type StaticProviderPolicyField =
| "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "showThinkingSummary"
| "escapeBuiltinToolNames" | "googleMode" | "project" | "location" | "modelCapabilities"
| "modelAutoCompactTokenLimits" | "modelSuppressSyntheticMax" | "modelReasoningSummaryDelivery"
- | "thinkingDisplay" | "modelThinkingDisplay"
| "codexToolMode" | "modelAdapters";
type StaticProviderPolicyShape = Pick;
export type ResolvedProviderStaticPolicy = Readonly> & Readonly<{
@@ -183,8 +182,6 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode
"supportsVerbosity", "responsesItemIdRepair",
"showThinkingSummary", "escapeBuiltinToolNames", "googleMode", "project", "location",
] as const) putScalar(key, entry?.[key] as StaticProviderPolicyShape[typeof key] | undefined);
- // Operator-only display policy; no registry entry declares it (#8363).
- put("thinkingDisplay", provider.thinkingDisplay, provider.thinkingDisplay !== undefined ? "operator" : "unknown");
const legacyClinePassLadder = entry !== undefined && input.providerName === "cline-pass"
&& provider.reasoningWireFormat === "gateway-object"
&& sameStringArray(provider.reasoningEfforts, ["low"]);
@@ -221,7 +218,6 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode
for (const key of [
"modelAutoCompactTokenLimits", "modelSuppressSyntheticMax",
"modelReasoningSummaryDelivery", "codexToolMode", "modelAdapters",
- "modelThinkingDisplay",
] as const) putScalar(key, undefined);
put("modelCapabilities", mergeReasoningModelCapabilities(entry?.modelCapabilities, provider.modelCapabilities),
provider.modelCapabilities !== undefined ? "operator-capability" : entry?.modelCapabilities !== undefined ? "registry" : "unknown");
diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts
index 40ba18f5c16..da6e6e32f31 100644
--- a/src/server/auth-cors.ts
+++ b/src/server/auth-cors.ts
@@ -29,8 +29,6 @@ import {
providerHeadersConfigError,
providerForwardClientHeadersConfigError,
reasoningSummaryDeliveryRecordConfigError,
- thinkingDisplayConfigError,
- modelThinkingDisplayConfigError,
upstreamHttpVersionConfigError,
credentialPinnedProviderConfigError,
} from "../config/provider-validation";
@@ -951,10 +949,6 @@ export function providerManagementConfigError(
raw.modelSupportsReasoningSummaries,
);
if (reasoningSummaryDeliveryError) return `provider ${name} ${reasoningSummaryDeliveryError}`;
- const thinkingDisplayError = thinkingDisplayConfigError(raw.thinkingDisplay);
- if (thinkingDisplayError) return `provider ${name} ${thinkingDisplayError}`;
- const modelThinkingDisplayError = modelThinkingDisplayConfigError(raw.modelThinkingDisplay);
- if (modelThinkingDisplayError) return `provider ${name} ${modelThinkingDisplayError}`;
const modelAdaptersError = modelAdapterRecordConfigError(raw.modelAdapters, "modelAdapters", name, typed);
if (modelAdaptersError) return `provider ${name} ${modelAdaptersError}`;
const credentialPinError = credentialPinnedProviderConfigError(name, typed);
@@ -1181,8 +1175,6 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
modelSupportsVerbosity: "editor",
supportsVerbosity: "editor",
modelReasoningSummaryDelivery: "editor",
- thinkingDisplay: "editor",
- modelThinkingDisplay: "editor",
modelPreferHostedTools: "editor",
supportsOpenAiWebSearchToolFields: "editor",
xaiResponsesXSearch: "editor",
diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts
index 4b205098427..6b1a9b0d550 100644
--- a/src/server/management/provider-overwrite-carry.ts
+++ b/src/server/management/provider-overwrite-carry.ts
@@ -60,13 +60,7 @@ export const PROVIDER_COMPAT_CARRY_FIELDS = [
...RETRY_POLICY_FIELDS,
] as const satisfies readonly (keyof OcxProviderConfig)[];
-export const PROVIDER_DISPLAY_CARRY_FIELDS = [
- "hideRawReasoning",
- // The Anthropic thinking display policy (#8363) is operator configuration the dashboard form
- // cannot send either, so an unrelated save must not silently return the model to the default.
- "thinkingDisplay",
- "modelThinkingDisplay",
-] as const satisfies readonly (keyof OcxProviderConfig)[];
+export const PROVIDER_DISPLAY_CARRY_FIELDS = ["hideRawReasoning"] as const satisfies readonly (keyof OcxProviderConfig)[];
export type ProviderCompatCarryField = typeof PROVIDER_COMPAT_CARRY_FIELDS[number];
type ProviderDisplayCarryField = typeof PROVIDER_DISPLAY_CARRY_FIELDS[number];
@@ -137,8 +131,7 @@ export function carryProviderCompatFields(
for (const field of PROVIDER_DISPLAY_CARRY_FIELDS) {
if (sample.submitted.has(field)) continue;
const stored = live[field];
- // Copy maps rather than alias them, like the compatibility settings below.
- if (stored !== undefined) target[field] = stored !== null && typeof stored === "object" ? structuredClone(stored) : stored;
+ if (stored !== undefined) target[field] = stored;
}
if (!providerOverwriteKeepsDestination(candidate, live, sample)) return;
for (const field of PROVIDER_COMPAT_CARRY_FIELDS) {
diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts
index 26bba95ceac..63e4089e46f 100644
--- a/src/server/management/provider-routes.ts
+++ b/src/server/management/provider-routes.ts
@@ -120,7 +120,6 @@ import {
} from "../auth-cors";
import { providerCatalogCapabilityConfigError } from "./provider-capability-config";
import { providerEmptyToolOutputConfigError } from "../../config/provider-validation";
-import { applyThinkingDisplayPatchFields } from "./provider-thinking-display-patch";
import { applySystemEnvToggle } from "../system-env";
import {
LOCAL_PROVIDER_RELOAD_NAME_HEADER,
@@ -689,11 +688,6 @@ function applyProviderPatchFields(
}
touched = true;
}
- if (Object.hasOwn(rawBody, "thinkingDisplay") || Object.hasOwn(rawBody, "modelThinkingDisplay")) {
- const displayError = applyThinkingDisplayPatchFields(next, rawBody);
- if (displayError) return displayError;
- touched = true;
- }
if (Object.hasOwn(rawBody, "noStructuredOutputModels")) {
const value = rawBody.noStructuredOutputModels;
if (value === null) {
diff --git a/src/server/management/provider-thinking-display-patch.ts b/src/server/management/provider-thinking-display-patch.ts
deleted file mode 100644
index 313278405d6..00000000000
--- a/src/server/management/provider-thinking-display-patch.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { OcxProviderConfig } from "../../types";
-import { thinkingDisplayConfigError } from "../../config/provider-validation";
-import { isPlainRecord } from "./shared";
-
-type ThinkingDisplay = NonNullable;
-
-/**
- * Apply the `thinkingDisplay` / `modelThinkingDisplay` PATCH mask (#8363).
- *
- * Split out of provider-routes.ts, which sits under the 2,000-line file-size threshold; the
- * behaviour stays identical to the sibling per-model map patches there. A model entry of
- * `null` deletes one key, `null` on the whole map clears it, and provider-level `null` or ""
- * clears the scalar.
- */
-export function applyThinkingDisplayPatchFields(
- next: OcxProviderConfig,
- rawBody: Record,
-): { error: string } | null {
- if (Object.hasOwn(rawBody, "thinkingDisplay")) {
- const value = rawBody.thinkingDisplay;
- if (value === null || value === "") {
- delete next.thinkingDisplay;
- } else {
- const error = thinkingDisplayConfigError(value);
- if (error) return { error };
- next.thinkingDisplay = value as ThinkingDisplay;
- }
- }
- if (Object.hasOwn(rawBody, "modelThinkingDisplay")) {
- const value = rawBody.modelThinkingDisplay;
- if (value === null) {
- delete next.modelThinkingDisplay;
- } else {
- if (!isPlainRecord(value)) return { error: "modelThinkingDisplay must be a plain object or null" };
- const merged = Object.assign(
- Object.create(null) as Record,
- next.modelThinkingDisplay ?? {},
- );
- for (const [model, policy] of Object.entries(value as Record)) {
- if (!model.trim()) return { error: "modelThinkingDisplay keys must be nonblank model ids" };
- if (policy === null) delete merged[model];
- else {
- const entryError = thinkingDisplayConfigError(policy, `modelThinkingDisplay.${JSON.stringify(model)}`);
- if (entryError) return { error: entryError };
- merged[model] = policy as ThinkingDisplay;
- }
- }
- if (Object.keys(merged).length > 0) next.modelThinkingDisplay = merged;
- else delete next.modelThinkingDisplay;
- }
- }
- return null;
-}
-
diff --git a/src/types.ts b/src/types.ts
index 1c7d579f3a7..8da67a3db8b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -24,14 +24,12 @@ export {
} from "./types/tools";
export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire";
-export type { ThinkingDisplayPolicy } from "./types/wire";
export type { AnthropicInstanceId } from "./providers/anthropic-instance-id";
export { ANTHROPIC_INSTANCE_IDS, ANTHROPIC_PRIMARY_INSTANCE, ANTHROPIC_POOL2_INSTANCE, isAnthropicInstanceId } from "./providers/anthropic-instance-id";
export type { AnthropicAccountPoolConfig, AnthropicModelRoute } from "./types/anthropic-account-pool";
export {
UPSTREAM_HTTP_VERSION_VALUES,
REASONING_SUMMARY_DELIVERY_VALUES,
- THINKING_DISPLAY_VALUES,
OPENAI_PROVIDER_TIER_VERSION,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
captureWireAdapterHardPins,
diff --git a/src/types/provider.ts b/src/types/provider.ts
index 9bd9c6708fe..22be33e0882 100644
--- a/src/types/provider.ts
+++ b/src/types/provider.ts
@@ -1,4 +1,4 @@
-import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode, ThinkingDisplayPolicy } from "./wire";
+import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./wire";
import type { AccountAutoPausePolicy, AccountPauseDetails } from "./account-auto-pause";
import type { AnthropicAccountPoolConfig } from "./anthropic-account-pool";
@@ -841,14 +841,6 @@ export interface OcxProviderConfig {
* Presence also advertises reasoning-summary support for that routed model.
*/
modelReasoningSummaryDelivery?: Record;
- /**
- * Anthropic adaptive `thinking.display` policy for this provider (#8363). `auto` and omission
- * keep the built-in selection: native Opus 5.5 progress updates, summarized thinking elsewhere.
- * `updates` is only sendable to native Opus 5.5 and degrades to `auto` for other models.
- */
- thinkingDisplay?: ThinkingDisplayPolicy;
- /** Per-model override of `thinkingDisplay`, keyed by the exact upstream model id. */
- modelThinkingDisplay?: Record;
/**
* Exact-model hosted tools that win collisions with Codex client tool declarations.
* Use for non-forward Responses gateways that reserve a hosted tool namespace server-side.
diff --git a/src/types/wire.ts b/src/types/wire.ts
index 8c3767889ea..2db38cbaa9d 100644
--- a/src/types/wire.ts
+++ b/src/types/wire.ts
@@ -25,16 +25,6 @@ export const REASONING_SUMMARY_DELIVERY_VALUES = [
export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number];
-/**
- * Anthropic adaptive `thinking.display` policy (#8363). `auto` is the built-in behavior:
- * native Opus 5.5 progress updates, summarized thinking elsewhere unless the caller hides it.
- * `updates` is only sendable to native Opus 5.5 and degrades to `auto` for every other model.
- * `omitted` sends adaptive thinking with no display field, matching hidden thinking.
- */
-export const THINKING_DISPLAY_VALUES = ["auto", "summarized", "updates", "omitted"] as const;
-
-export type ThinkingDisplayPolicy = (typeof THINKING_DISPLAY_VALUES)[number];
-
/** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */
export type CodexAccountMode = "direct" | "pool";
diff --git a/structure/providers/anthropic-progress.md b/structure/providers/anthropic-progress.md
index 21719d60fed..5ca176a4184 100644
--- a/structure/providers/anthropic-progress.md
+++ b/structure/providers/anthropic-progress.md
@@ -8,12 +8,6 @@ This display choice also applies when effort is absent or `none`; it does not ad
Omitted summary, `auto` (the Codex default) and `none` keep the updates default. Local `hideThinkingSummary` controls summary
presentation independently; it does not select the upstream display. The selected display controls
both the generated beta header and response classification on every request, including adapter reuse.
-An operator override sits above that default without changing it when unset (#8363): the provider
-`thinkingDisplay` value, or the exact-model `modelThinkingDisplay` entry that wins over it, can force
-`summarized`, keep `updates`, or select `omitted` (adaptive thinking with no display field). `updates`
-is treated as `auto` on any model this adapter never sent it to, an explicit request summary still
-forces `summarized` on every adaptive model, and `hideThinkingSummary` keeps its meaning only under
-the `auto` policy. Every branch derives `progressUpdates` from the final body's `display`.
`src/types/request.ts` carries the complete signed update on its text-delta event.
`src/adapters/anthropic-progress.ts` uses this documented display contract to classify nonempty
thinking text as commentary, buffering one block under the translator budget until its final signature.
diff --git a/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts b/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts
deleted file mode 100644
index 1f78fd48537..00000000000
--- a/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { expect, test } from "bun:test";
-import { createAnthropicAdapter } from "../../../src/adapters/anthropic";
-import { parseRequest } from "../../../src/responses/parser";
-import type { OcxProviderConfig } from "../../../src/types";
-
-const base = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "synthetic" } as const;
-// Routed requests reach the adapter with the native wire id, which is also the key these maps use.
-const opus55 = "claude-opus-5-5";
-const sonnet5 = "claude-sonnet-5";
-const updatesBeta = "thinking-display-updates-2026-08-18";
-
-async function requestBody(
- provider: OcxProviderConfig,
- model: string,
- reasoning?: { effort?: string; summary?: string },
-): Promise<{ thinking: unknown; beta: string | null }> {
- const adapter = createAnthropicAdapter(provider);
- const request = await adapter.buildRequest(parseRequest({
- model,
- input: "question",
- ...(reasoning ? { reasoning } : {}),
- }));
- return {
- thinking: JSON.parse(request.body as string).thinking,
- beta: new Headers(request.headers).get("anthropic-beta"),
- };
-}
-
-test("modelThinkingDisplay summarized sends summarized for native Opus 5.5 with bare effort", async () => {
- const { thinking, beta } = await requestBody({ ...base, modelThinkingDisplay: { "claude-opus-5-5": "summarized" } }, opus55, { effort: "high" });
- expect(thinking).toEqual({ type: "adaptive", display: "summarized" });
- expect(beta ?? "").not.toContain(updatesBeta);
-});
-
-test("provider-level summarized applies when no model entry exists", async () => {
- const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "summarized" }, opus55);
- expect(thinking).toEqual({ type: "adaptive", display: "summarized" });
- expect(beta ?? "").not.toContain(updatesBeta);
-});
-
-test("model-level updates overrides provider summarized for Opus 5.5", async () => {
- const { thinking, beta } = await requestBody({
- ...base,
- thinkingDisplay: "summarized",
- modelThinkingDisplay: { "claude-opus-5-5": "updates" },
- }, opus55, { effort: "high" });
- expect(thinking).toEqual({ type: "adaptive", display: "updates" });
- expect(beta).toContain(updatesBeta);
-});
-
-test("updates configured for a non-Opus-5.5 adaptive model keeps current behavior", async () => {
- const configured = await requestBody({ ...base, thinkingDisplay: "updates" }, sonnet5, { effort: "high" });
- const unset = await requestBody({ ...base }, sonnet5, { effort: "high" });
- expect(configured.thinking).toEqual(unset.thinking);
- expect(configured.thinking).toEqual({ type: "adaptive", display: "summarized" });
- expect(configured.beta ?? "").not.toContain(updatesBeta);
-});
-
-test("model-level auto overrides a provider-level policy for that model", async () => {
- const { thinking, beta } = await requestBody({
- ...base,
- thinkingDisplay: "summarized",
- modelThinkingDisplay: { "claude-opus-5-5": "auto" },
- }, opus55, { effort: "high" });
- expect(thinking).toEqual({ type: "adaptive", display: "updates" });
- expect(beta).toContain(updatesBeta);
-
- // The provider value still applies to a model with no entry of its own.
- const sonnet = await requestBody({ ...base, thinkingDisplay: "summarized" }, sonnet5, { effort: "high" });
- expect(sonnet.thinking).toEqual({ type: "adaptive", display: "summarized" });
-});
-
-test("explicit request summary detailed still wins over configured updates", async () => {
- const { thinking, beta } = await requestBody({
- ...base,
- thinkingDisplay: "updates",
- modelThinkingDisplay: { "claude-opus-5-5": "updates" },
- }, opus55, { effort: "high", summary: "detailed" });
- expect(thinking).toEqual({ type: "adaptive", display: "summarized" });
- expect(beta ?? "").not.toContain(updatesBeta);
-});
-
-test("explicit request summary wins over configured omitted on every adaptive model", async () => {
- for (const model of [opus55, sonnet5]) {
- for (const summary of ["concise", "detailed"] as const) {
- const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "omitted" }, model, { effort: "high", summary });
- expect(thinking).toEqual({ type: "adaptive", display: "summarized" });
- expect(beta ?? "").not.toContain(updatesBeta);
- }
- }
-});
-
-test("unset policy keeps Opus 5.5 updates and non-Opus summarized byte-identically", async () => {
- const opus = await requestBody({ ...base }, opus55, { effort: "high" });
- expect(opus.thinking).toEqual({ type: "adaptive", display: "updates" });
- expect(opus.beta).toContain(updatesBeta);
- const sonnet = await requestBody({ ...base }, sonnet5, { effort: "high" });
- expect(sonnet.thinking).toEqual({ type: "adaptive", display: "summarized" });
-});
-
-test("auto policy keeps hideThinkingSummary meaning on adaptive models", async () => {
- const adapter = createAnthropicAdapter({ ...base, thinkingDisplay: "auto" });
- const parsed = parseRequest({ model: sonnet5, input: "question", reasoning: { effort: "high" } });
- parsed.options.hideThinkingSummary = true;
- const request = await adapter.buildRequest(parsed);
- expect(JSON.parse(request.body as string).thinking).toEqual({ type: "adaptive" });
-});
-
-test("configured summarized overrides local summary hiding on adaptive models", async () => {
- for (const model of [opus55, sonnet5]) {
- const adapter = createAnthropicAdapter({ ...base, thinkingDisplay: "summarized" });
- const parsed = parseRequest({ model, input: "question", reasoning: { effort: "high" } });
- parsed.options.hideThinkingSummary = true;
- const request = await adapter.buildRequest(parsed);
- expect(JSON.parse(request.body as string).thinking).toEqual({ type: "adaptive", display: "summarized" });
- }
-});
-
-test("omitted policy sends adaptive thinking without a summarized display", async () => {
- for (const model of [opus55, sonnet5]) {
- const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "omitted" }, model, { effort: "high" });
- expect(thinking).toEqual({ type: "adaptive" });
- expect(beta ?? "").not.toContain(updatesBeta);
- }
-});
diff --git a/tests/config/config-thinking-display.test.ts b/tests/config/config-thinking-display.test.ts
deleted file mode 100644
index dcb3f2e8584..00000000000
--- a/tests/config/config-thinking-display.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { getDefaultConfig, validateConfigCandidate } from "../../src/config";
-import type { OcxConfig } from "../../src/types";
-
-function withProvider(provider: Record): OcxConfig {
- return {
- ...getDefaultConfig(),
- defaultProvider: "anthropic",
- providers: {
- anthropic: {
- adapter: "anthropic",
- baseUrl: "https://api.anthropic.com",
- apiKey: "synthetic",
- ...provider,
- },
- },
- } as OcxConfig;
-}
-
-describe("anthropic thinking display policy config", () => {
- test("accepts a model-keyed summarized entry, mirroring other model maps", () => {
- const result = validateConfigCandidate(withProvider({
- modelThinkingDisplay: { "claude-opus-5-5": "summarized" },
- }));
- expect(result.ok).toBe(true);
- if (result.ok) expect(result.config.providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" });
- });
-
- test("accepts every documented value at provider and model level", () => {
- for (const value of ["auto", "summarized", "updates", "omitted"] as const) {
- expect(validateConfigCandidate(withProvider({ thinkingDisplay: value })).ok).toBe(true);
- expect(validateConfigCandidate(withProvider({ modelThinkingDisplay: { "claude-opus-5-5": value } })).ok).toBe(true);
- }
- });
-
- test("rejects an unknown provider-level value with a clear message", () => {
- const result = validateConfigCandidate(withProvider({ thinkingDisplay: "always" }));
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.error).toContain("thinkingDisplay");
- });
-
- test("rejects an unknown model-level value with a clear message", () => {
- const result = validateConfigCandidate(withProvider({
- modelThinkingDisplay: { "claude-opus-5-5": "verbose" },
- }));
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.error).toContain("modelThinkingDisplay");
- });
-
- test("rejects a non-object model map and non-string values", () => {
- for (const value of [[], "summarized", { "claude-opus-5-5": true }]) {
- const result = validateConfigCandidate(withProvider({ modelThinkingDisplay: value }));
- expect(result.ok).toBe(false);
- }
- });
-});
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 3c964189a0d..04b709c524b 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -355,7 +355,6 @@
"anthropic-cooldown-recovery.test.ts": "adapters/anthropic", "anthropic-empty-content.test.ts": "adapters/anthropic",
"anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic",
"anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-fast-opt-in.test.ts": "adapters/anthropic",
- "anthropic-thinking-display-policy.test.ts": "adapters/anthropic",
"anthropic-fast-speed.test.ts": "adapters/anthropic", "anthropic-hardening.test.ts": "adapters/anthropic",
"anthropic-image-guard.test.ts": "adapters/anthropic", "anthropic-image-normalize.test.ts": "adapters/anthropic",
"anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic",
@@ -775,7 +774,6 @@
"config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config",
"config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config",
"config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config",
- "config-thinking-display.test.ts": "config",
"config.test.ts": "server", "configured-native-models.test.ts": "codex-integration",
"gui-desktop-sidecar-signing.test.ts": "gui", "linux-desktop-packaged-ci.test.ts": "ci-workflows",
"probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing",
@@ -1048,7 +1046,6 @@
"management-provider-reasoning-lists.test.ts": "server", "management-provider-reset-replay.test.ts": "server",
"management-provider-synthetic-max.test.ts": "server", "management-provider-upstream-websocket.test.ts": "server",
"management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server",
- "management-provider-thinking-display.test.ts": "server",
"management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server",
"protocol-settings-route.test.ts": "server", "protocol-provider-summary.test.ts": "server",
"management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service",
diff --git a/tests/server/management-provider-thinking-display.test.ts b/tests/server/management-provider-thinking-display.test.ts
deleted file mode 100644
index bfd1804ee5c..00000000000
--- a/tests/server/management-provider-thinking-display.test.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * Management-boundary coverage for the Anthropic thinking display policy (#8363).
- *
- * A sibling file rather than an addition to management-provider-validation.test.ts, which
- * sits at its file-size ratchet cap (the same reason recorded in d3ca5522db and #4908).
- */
-import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test";
-import { managementFetch as fetch } from "../helpers/management-auth";
-import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { loadConfig, saveConfig } from "../../src/config";
-import { startServer } from "../../src/server";
-import { providerManagementConfigError } from "../../src/server/auth-cors";
-import * as destinationPolicy from "../../src/lib/destination-policy";
-import { removeTreeWithRetry } from "../helpers/remove-tree";
-import type { OcxConfig } from "../../src/types";
-
-setDefaultTimeout(60_000);
-
-const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-management-thinking-display-"));
-const previousHome = process.env.OPENCODEX_HOME;
-
-function seedConfig(): OcxConfig {
- return {
- port: 0,
- hostname: "127.0.0.1",
- defaultProvider: "anthropic",
- providers: {
- anthropic: {
- adapter: "anthropic",
- baseUrl: "https://api.anthropic.com",
- apiKey: "synthetic",
- thinkingDisplay: "summarized",
- },
- },
- };
-}
-
-afterEach(() => {
- if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
- else process.env.OPENCODEX_HOME = previousHome;
- if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR);
-});
-
-describe("provider management thinking display policy", () => {
- test("shared validator rejects unknown provider and model values", () => {
- const provider = { adapter: "anthropic" as const, baseUrl: "https://api.anthropic.com" };
- expect(providerManagementConfigError("anthropic", { ...provider, thinkingDisplay: "summarized" })).toBeNull();
- expect(providerManagementConfigError("anthropic", { ...provider, modelThinkingDisplay: { "claude-opus-5-5": "omitted" } })).toBeNull();
- expect(providerManagementConfigError("anthropic", { ...provider, thinkingDisplay: "always" })).toContain("thinkingDisplay");
- expect(providerManagementConfigError("anthropic", {
- ...provider,
- modelThinkingDisplay: { "claude-opus-5-5": "verbose" },
- })).toContain("modelThinkingDisplay");
- });
-
- test("PATCH merges model entries, clears one with null, and rejects an unknown value", async () => {
- mkdirSync(TEST_DIR, { recursive: true });
- process.env.OPENCODEX_HOME = TEST_DIR;
- saveConfig(seedConfig());
- const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null);
- const server = startServer(0);
- try {
- const patch = (body: unknown) => fetch(new URL("/api/providers?name=anthropic", server.url), {
- method: "PATCH",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
-
- const merged = await patch({ modelThinkingDisplay: { "claude-opus-5-5": "summarized" } });
- expect(merged.status).toBe(200);
- expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" });
-
- const rejected = await patch({ modelThinkingDisplay: { "claude-opus-5-5": "verbose" } });
- expect(rejected.status).toBe(400);
- expect(await rejected.json()).toEqual(expect.objectContaining({
- error: expect.stringContaining("modelThinkingDisplay"),
- }));
- expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" });
-
- const cleared = await patch({ modelThinkingDisplay: { "claude-opus-5-5": null } });
- expect(cleared.status).toBe(200);
- expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toBeUndefined();
-
- const clearedProvider = await patch({ thinkingDisplay: null });
- expect(clearedProvider.status).toBe(200);
- expect(loadConfig().providers.anthropic!.thinkingDisplay).toBeUndefined();
- } finally {
- await server.stop(true);
- resolvedError.mockRestore();
- }
- });
-
- test("same-destination POST keeps a policy the dashboard form cannot send", async () => {
- mkdirSync(TEST_DIR, { recursive: true });
- process.env.OPENCODEX_HOME = TEST_DIR;
- saveConfig({
- ...seedConfig(),
- providers: {
- anthropic: {
- ...seedConfig().providers.anthropic!,
- modelThinkingDisplay: { "claude-opus-5-5": "updates" },
- },
- },
- });
- const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null);
- const server = startServer(0);
- try {
- const response = await fetch(new URL("/api/providers", server.url), {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- name: "anthropic",
- provider: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "synthetic", defaultModel: "claude-opus-5-5" },
- }),
- });
- expect(response.status).toBe(200);
- const saved = loadConfig().providers.anthropic!;
- expect(saved.thinkingDisplay).toBe("summarized");
- expect(saved.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "updates" });
- } finally {
- await server.stop(true);
- resolvedError.mockRestore();
- }
- });
-});
-
From 822cf4e7b724b164f19b3065800d357289f1248a Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:37 +0900
Subject: [PATCH 11/14] Revert "docs: scope the Remember proxy refusal to
standalone dashboards and list its code (#8394)"
This reverts commit 4995e7c42f09b0a73f0852f40c8ade886b1ec677.
---
docs-site/src/content/docs/fr/guides/web-dashboard.md | 2 +-
docs-site/src/content/docs/guides/web-dashboard.md | 2 +-
docs-site/src/content/docs/ja/guides/web-dashboard.md | 2 +-
docs-site/src/content/docs/ko/guides/web-dashboard.md | 2 +-
docs-site/src/content/docs/reference/management-api.md | 1 -
docs-site/src/content/docs/ru/guides/web-dashboard.md | 2 +-
docs-site/src/content/docs/tr/guides/web-dashboard.md | 2 +-
docs-site/src/content/docs/zh-cn/guides/web-dashboard.md | 2 +-
docs-site/src/content/docs/zh-tw/guides/web-dashboard.md | 2 +-
9 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md
index f760aafe574..fb00d4b6c07 100644
--- a/docs-site/src/content/docs/fr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md
@@ -39,7 +39,7 @@ remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en
dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du
gestionnaire de mots de passe.
-**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie d’appareil HttpOnly, SameSite=Strict et une preuve distincte stockée dans le localStorage de ce site. Le cookie est Secure en HTTPS et limité à l’hôte. Ni le cookie ni la preuve ne sont le jeton administrateur, et aucun ne fonctionne seul. Les cookies sont partagés entre les ports d’un même nom d’hôte ; la preuve, limitée à l’origine, lie l’appareil mémorisé à l’origine de ce tableau de bord. Préférez tout de même un nom d’hôte dédié. Le jeton administrateur n’est jamais écrit dans localStorage ou sessionStorage. Si le stockage du navigateur est indisponible ou ne peut enregistrer la preuve, la mémorisation est signalée comme indisponible. Pour un tableau de bord autonome derrière un proxy inverse qui termine TLS, OpenCodex voit une requête HTTP simple et refuse donc la mémorisation pour cette adresse ; la fenêtre de connexion l’explique et désactive l’option, et la connexion sans mémorisation fonctionne toujours. Un écouteur de gestion Hub de confiance fait exception et peut toujours mémoriser les appareils.
+**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie d’appareil HttpOnly, SameSite=Strict et une preuve distincte stockée dans le localStorage de ce site. Le cookie est Secure en HTTPS et limité à l’hôte. Ni le cookie ni la preuve ne sont le jeton administrateur, et aucun ne fonctionne seul. Les cookies sont partagés entre les ports d’un même nom d’hôte ; la preuve, limitée à l’origine, lie l’appareil mémorisé à l’origine de ce tableau de bord. Préférez tout de même un nom d’hôte dédié. Le jeton administrateur n’est jamais écrit dans localStorage ou sessionStorage. Si le stockage du navigateur est indisponible ou ne peut enregistrer la preuve, la mémorisation est signalée comme indisponible. Derrière un proxy inverse qui termine TLS, OpenCodex voit une requête HTTP simple et refuse donc la mémorisation pour cette adresse ; la fenêtre de connexion l’explique et désactive l’option, et la connexion sans mémorisation fonctionne toujours.
Le fichier privé dashboard-sessions.json conserve les empreintes du cookie, de la preuve, du jeton administrateur et de l’identité du navigateur, l’origine du serveur et une expiration fixe de 30 jours. La relance de l’application Safari reprend une session courte tant que cet appareil reste autorisé. Les sessions mémorisées ne peuvent pas approuver les actions de consentement : étoile GitHub, consommation de crédits de réinitialisation, modification des couches de prompt, connexion Meta Muse, changements de compatibilité du bureau ou validation des quotas. Utilisez une nouvelle session locale ou appairée pour ces actions. Les tableaux de bord relayés, inter-origines ou HTTP distant ne prennent pas en charge la mémorisation. L’expiration, un changement d’identité du navigateur ou la rotation du jeton suivie d’un redémarrage imposent une nouvelle connexion.
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index 7d3eb09c232..07c74760856 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -37,7 +37,7 @@ password manager can offer to save and autofill it. The dashboard itself still k
in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely
the browser or password manager's decision.
-**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie plus a separate device proof stored in this site's localStorage. HTTPS cookies are Secure and host-only. Neither the cookie nor the proof is the admin token, and neither works alone. Cookies are shared across ports of the same hostname; the origin-scoped proof binds the remembered device to this dashboard origin. Still prefer a dedicated hostname. The admin token is never written to localStorage or sessionStorage. If browser storage is unavailable or cannot save the proof, Remember reports unavailable. On a standalone dashboard behind a TLS-terminating reverse proxy, OpenCodex sees a plain HTTP request, so it refuses Remember for that address; the sign-in dialog says so and turns the option off, and signing in without Remember still works. A trusted Hub management listener is the exception and can still remember devices.
+**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie plus a separate device proof stored in this site's localStorage. HTTPS cookies are Secure and host-only. Neither the cookie nor the proof is the admin token, and neither works alone. Cookies are shared across ports of the same hostname; the origin-scoped proof binds the remembered device to this dashboard origin. Still prefer a dedicated hostname. The admin token is never written to localStorage or sessionStorage. If browser storage is unavailable or cannot save the proof, Remember reports unavailable. Behind a TLS-terminating reverse proxy OpenCodex sees a plain HTTP request, so it refuses Remember for that address; the sign-in dialog says so and turns the option off, and signing in without Remember still works.
The private dashboard-sessions.json stores hashes of the cookie, proof, admin credential and browser identity, the server origin and a fixed 30-day expiry. Relaunching the Safari home-screen app resumes a short-lived GUI session while that device record is valid. Remembered sessions cannot approve consent actions: GitHub stars, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes or quota validation. Use a fresh local or paired session for those actions. Cross-origin/relayed dashboards and remote plaintext HTTP do not support Remember. Expiry, a browser identity change, or rotating the admin token and restarting the server requires sign-in again.
diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md
index 1f75438e846..416b97a13f8 100644
--- a/docs-site/src/content/docs/ja/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md
@@ -27,7 +27,7 @@ bun run dev:gui
リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。
-**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict デバイス Cookie と、このサイトの localStorage に保存する別のデバイス証明に交換します。HTTPS の Cookie は Secure でホスト限定です。Cookie も証明も管理者トークンではなく、どちらも単独では機能しません。同じホスト名のポート間では Cookie が共有されるため、オリジン単位の証明が記憶したデバイスをこのダッシュボードのオリジンに結び付けます。それでも専用ホスト名を推奨します。管理者トークンは localStorage や sessionStorage に保存しません。ブラウザーのストレージが使えない、または証明を保存できない場合、記憶機能は利用不可と表示されます。スタンドアロンのダッシュボードを TLS を終端するリバースプロキシ経由で使う場合、 OpenCodex には通常の HTTP リクエストとして見えるため、そのアドレスでは記憶を拒否します。サインイン画面がその旨を表示してオプションをオフにし、記憶なしのサインインは引き続き使えます。信頼された Hub の管理リスナーは例外で、引き続きデバイスを記憶できます。
+**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict デバイス Cookie と、このサイトの localStorage に保存する別のデバイス証明に交換します。HTTPS の Cookie は Secure でホスト限定です。Cookie も証明も管理者トークンではなく、どちらも単独では機能しません。同じホスト名のポート間では Cookie が共有されるため、オリジン単位の証明が記憶したデバイスをこのダッシュボードのオリジンに結び付けます。それでも専用ホスト名を推奨します。管理者トークンは localStorage や sessionStorage に保存しません。ブラウザーのストレージが使えない、または証明を保存できない場合、記憶機能は利用不可と表示されます。TLS を終端するリバースプロキシ経由では OpenCodex には通常の HTTP リクエストとして見えるため、そのアドレスでは記憶を拒否します。サインイン画面がその旨を表示してオプションをオフにし、記憶なしのサインインは引き続き使えます。
非公開の dashboard-sessions.json は Cookie・証明・管理者認証情報・ブラウザー識別のハッシュ、サーバーのオリジンと固定30日間の有効期限を保存します。Safari ホーム画面アプリを再起動すると、有効なデバイス記録から短期セッションを再開します。記憶したセッションでは、GitHub スター、リセットクレジットの消費、プロンプト層の編集、Meta Muse サインイン、デスクトップ互換性の変更、クォータ検証などの同意操作を承認できません。これらには新しいローカルまたはペアリング済みセッションを使ってください。中継・別オリジン・リモートの平文 HTTP は記憶機能に対応しません。期限切れ、ブラウザー識別の変更、管理者トークン変更後のサーバー再起動では再ログインが必要です。
diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md
index 46eb7ab9120..0c65fc98c23 100644
--- a/docs-site/src/content/docs/ko/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md
@@ -27,7 +27,7 @@ bun run dev:gui
원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다.
-**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 기기 쿠키와 이 사이트의 localStorage에 저장하는 별도의 기기 증명으로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 쿠키와 증명은 모두 관리자 토큰이 아니며, 어느 쪽도 단독으로 작동하지 않습니다. 같은 호스트명의 여러 포트는 쿠키를 공유하므로, 오리진별 저장소의 증명이 기억한 기기를 이 대시보드 오리진에 묶습니다. 그래도 전용 호스트명을 권장합니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 브라우저 저장소를 사용할 수 없거나 증명 저장에 실패하면 기억하기는 사용 불가로 표시됩니다. 독립 실행형 대시보드가 TLS를 종료하는 리버스 프록시 뒤에 있으면 OpenCodex가 일반 HTTP 요청으로 보기 때문에 해당 주소에서는 기억하기를 거부합니다. 로그인 창이 이를 안내하고 옵션을 끄며, 기억하기 없이 로그인하는 것은 계속 가능합니다. 신뢰된 Hub 관리 리스너는 예외이며 계속 기기를 기억할 수 있습니다.
+**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 기기 쿠키와 이 사이트의 localStorage에 저장하는 별도의 기기 증명으로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 쿠키와 증명은 모두 관리자 토큰이 아니며, 어느 쪽도 단독으로 작동하지 않습니다. 같은 호스트명의 여러 포트는 쿠키를 공유하므로, 오리진별 저장소의 증명이 기억한 기기를 이 대시보드 오리진에 묶습니다. 그래도 전용 호스트명을 권장합니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 브라우저 저장소를 사용할 수 없거나 증명 저장에 실패하면 기억하기는 사용 불가로 표시됩니다. TLS를 종료하는 리버스 프록시 뒤에서는 OpenCodex가 일반 HTTP 요청으로 보기 때문에 해당 주소에서는 기억하기를 거부합니다. 로그인 창이 이를 안내하고 옵션을 끄며, 기억하기 없이 로그인하는 것은 계속 가능합니다.
비공개 dashboard-sessions.json에는 쿠키·증명·관리자 인증 정보·브라우저 식별 해시, 서버 오리진과 고정 30일 만료를 저장합니다. Safari 홈 화면 앱을 다시 열면 유효한 기기 기록으로 단기 세션을 재개합니다. 기억한 세션으로는 GitHub 스타, 리셋 크레딧 소비, 프롬프트 계층 편집, Meta Muse 로그인, 데스크톱 호환성 변경, 할당량 검증에 동의할 수 없습니다. 이 작업에는 새 로컬 세션이나 페어링한 세션을 사용하세요. 릴레이·다른 오리진·원격 평문 HTTP에서는 기억하기를 지원하지 않습니다. 만료, 브라우저 식별 변경, 관리자 토큰 교체 후 서버 재시작 시 다시 로그인해야 합니다.
diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md
index e4413ff306c..8e97df84875 100644
--- a/docs-site/src/content/docs/reference/management-api.md
+++ b/docs-site/src/content/docs/reference/management-api.md
@@ -61,7 +61,6 @@ route-specific results rather than repeating this table.
| --- | --- | --- |
| 401 | `opencodex admin token required` | The admin token or GUI session is missing, invalid, expired, origin-mismatched, or missing CSRF evidence |
| 403 | `cross-origin request blocked` | The request origin is outside the management allowlist |
-| 403 | `cross-origin request blocked` with `code: "remember_requires_direct_origin"` | `POST /api/session/remember` only: this dashboard address cannot hold a remembered device (for example a standalone dashboard behind a TLS-terminating proxy); sign in without Remember |
| 404 | `not_found` | No management route matched the method and path |
| 413 | `request body too large` | A POST, PUT, or PATCH body exceeds the 2 MiB management limit |
| 503 | `management API unavailable` | Admin credential initialization or hardening is unavailable |
diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md
index 898f3d1e816..a0b34524047 100644
--- a/docs-site/src/content/docs/ru/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md
@@ -30,7 +30,7 @@ bun run dev:gui
Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей.
-**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер обменивает токен администратора на cookie устройства HttpOnly, SameSite=Strict и отдельное доказательство устройства в localStorage этого сайта. В HTTPS cookie имеет Secure и привязку к хосту. Ни cookie, ни доказательство не являются токеном администратора и не работают по отдельности. Cookie общие для портов одного имени хоста; доказательство в хранилище конкретного origin привязывает запомненное устройство к origin этой панели. Всё же предпочтительно отдельное имя хоста. Токен администратора не записывается в localStorage или sessionStorage. Если хранилище браузера недоступно или доказательство нельзя сохранить, запоминание сообщает о недоступности. Для автономной панели за обратным прокси, который завершает TLS, OpenCodex видит обычный HTTP-запрос и поэтому отказывает в запоминании для этого адреса; окно входа сообщает об этом и отключает опцию, а вход без запоминания по-прежнему работает. Исключение — доверенный управляющий слушатель Hub: он по-прежнему может запоминать устройства.
+**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер обменивает токен администратора на cookie устройства HttpOnly, SameSite=Strict и отдельное доказательство устройства в localStorage этого сайта. В HTTPS cookie имеет Secure и привязку к хосту. Ни cookie, ни доказательство не являются токеном администратора и не работают по отдельности. Cookie общие для портов одного имени хоста; доказательство в хранилище конкретного origin привязывает запомненное устройство к origin этой панели. Всё же предпочтительно отдельное имя хоста. Токен администратора не записывается в localStorage или sessionStorage. Если хранилище браузера недоступно или доказательство нельзя сохранить, запоминание сообщает о недоступности. За обратным прокси, который завершает TLS, OpenCodex видит обычный HTTP-запрос и поэтому отказывает в запоминании для этого адреса; окно входа сообщает об этом и отключает опцию, а вход без запоминания по-прежнему работает.
Приватный dashboard-sessions.json хранит хеши cookie, доказательства, учётных данных администратора и идентичности браузера, origin сервера и фиксированный срок 30 дней. Повторный запуск приложения Safari возобновляет короткую сессию, пока запись действительна. Запомненные сессии не могут подтверждать действия, требующие согласия: звезду GitHub, расход кредитов сброса, изменение слоёв промпта, вход Meta Muse, изменения совместимости настольного приложения или проверку квот. Для них используйте новую локальную или сопряжённую сессию. Релейный доступ, доступ с другого origin и удалённый HTTP без шифрования не поддерживают запоминание. Истечение срока, смена идентичности браузера или смена токена с перезапуском сервера требуют нового входа.
diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md
index 6d61e4cf94c..a0ba1f0d84f 100644
--- a/docs-site/src/content/docs/tr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md
@@ -41,7 +41,7 @@ yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz;
kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin
kararıdır.
-**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çerezi ve bu sitenin localStorage alanında saklanan ayrı bir cihaz kanıtıyla değiştirilir. HTTPS çerezi Secure ve ana makineyle sınırlıdır. Çerez de kanıt da yönetici belirteci değildir; hiçbiri tek başına çalışmaz. Aynı ana makine adının portları çerezleri paylaşır; origin kapsamındaki kanıt, hatırlanan cihazı bu panelin origin’ine bağlar. Yine de ayrı bir ana makine adı tercih edin. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Tarayıcı depolaması kullanılamıyorsa veya kanıt kaydedilemiyorsa Hatırla işlemi kullanılamadığını bildirir. TLS'i sonlandıran bir ters proxy arkasındaki bağımsız bir panelde OpenCodex düz bir HTTP isteği görür ve bu adres için Hatırla'yı reddeder; oturum açma penceresi bunu açıklar ve seçeneği kapatır, Hatırla olmadan oturum açmak çalışmaya devam eder. Güvenilen bir Hub yönetim dinleyicisi istisnadır ve cihazları hatırlamaya devam edebilir.
+**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çerezi ve bu sitenin localStorage alanında saklanan ayrı bir cihaz kanıtıyla değiştirilir. HTTPS çerezi Secure ve ana makineyle sınırlıdır. Çerez de kanıt da yönetici belirteci değildir; hiçbiri tek başına çalışmaz. Aynı ana makine adının portları çerezleri paylaşır; origin kapsamındaki kanıt, hatırlanan cihazı bu panelin origin’ine bağlar. Yine de ayrı bir ana makine adı tercih edin. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Tarayıcı depolaması kullanılamıyorsa veya kanıt kaydedilemiyorsa Hatırla işlemi kullanılamadığını bildirir. TLS'i sonlandıran bir ters proxy arkasında OpenCodex düz bir HTTP isteği görür ve bu adres için Hatırla'yı reddeder; oturum açma penceresi bunu açıklar ve seçeneği kapatır, Hatırla olmadan oturum açmak çalışmaya devam eder.
Özel dashboard-sessions.json çerezin, kanıtın, yönetici kimlik bilgisinin ve tarayıcı kimliğinin özetlerini, sunucu origin’ini ve sabit 30 günlük süreyi saklar. Safari ana ekran uygulaması yeniden açılınca geçerli cihaz kaydı kısa bir oturumu yeniler. Hatırlanan oturumlar onay gerektiren işlemleri yetkilendiremez: GitHub yıldızı, sıfırlama kredisi tüketimi, istem katmanı düzenlemeleri, Meta Muse girişi, masaüstü uyumluluk değişiklikleri veya kota doğrulaması. Bunlar için yeni bir yerel veya eşleştirilmiş oturum kullanın. Relay, başka origin ve uzak düz HTTP Hatırla seçeneğini desteklemez. Süre dolması, tarayıcı kimliği değişmesi veya belirtecin değiştirilip sunucunun yeniden başlatılması yeniden giriş gerektirir.
diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
index f68326e0d74..091073fa25d 100644
--- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
@@ -26,7 +26,7 @@ bun run dev:gui
远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。
-**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie,以及单独存入本站 localStorage 的设备证明。HTTPS 下 Cookie 启用 Secure 且仅限本主机。Cookie 和证明都不是管理员令牌,且都不能单独使用。同一主机名的不同端口共享 Cookie;按来源隔离的证明将记住的设备绑定到本仪表板来源。仍建议使用专用主机名。管理员令牌不存入 localStorage 或 sessionStorage。浏览器存储不可用或无法保存证明时,记住设备会报告不可用。对于位于终止 TLS 的反向代理之后的独立仪表板,OpenCodex 看到的是普通 HTTP 请求,因此会拒绝为该地址记住设备;登录对话框会说明原因并关闭该选项,不勾选记住设备仍可正常登录。受信任的 Hub 管理监听器例外,仍可记住设备。
+**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie,以及单独存入本站 localStorage 的设备证明。HTTPS 下 Cookie 启用 Secure 且仅限本主机。Cookie 和证明都不是管理员令牌,且都不能单独使用。同一主机名的不同端口共享 Cookie;按来源隔离的证明将记住的设备绑定到本仪表板来源。仍建议使用专用主机名。管理员令牌不存入 localStorage 或 sessionStorage。浏览器存储不可用或无法保存证明时,记住设备会报告不可用。在终止 TLS 的反向代理之后,OpenCodex 看到的是普通 HTTP 请求,因此会拒绝为该地址记住设备;登录对话框会说明原因并关闭该选项,不勾选记住设备仍可正常登录。
私有 dashboard-sessions.json 保存 Cookie、证明、管理员凭据和浏览器标识的哈希、服务器来源及固定30天有效期。重新打开 Safari 主屏幕应用时,有效设备记录可恢复短期会话。记住的会话不能批准需要同意的操作:GitHub 星标、重置额度消费、提示词层编辑、Meta Muse 登录、桌面兼容性更改或配额验证。这些操作请使用新的本地会话或已配对会话。中继、跨来源和远程明文 HTTP 不支持记住设备。过期、浏览器标识改变,或更换管理员令牌并重启服务器后需重新登录。
diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
index 175483cac74..64b8349176b 100644
--- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
@@ -31,7 +31,7 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默
儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全
由瀏覽器或密碼管理員決定。
-**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie,以及另外存入本站 localStorage 的裝置證明。HTTPS 下 Cookie 使用 Secure,且僅限本主機。Cookie 和證明都不是管理員權杖,且都不能單獨使用。同一主機名稱的不同連接埠共用 Cookie;依來源隔離的證明將記住的裝置綁定至本儀表板來源。仍建議使用專用主機名稱。管理員權杖不存入 localStorage 或 sessionStorage。瀏覽器儲存空間無法使用或無法儲存證明時,記住裝置會回報無法使用。對於位於終止 TLS 的反向代理後方的獨立儀表板,OpenCodex 看到的是一般 HTTP 請求,因此會拒絕為該位址記住裝置;登入對話框會說明原因並關閉該選項,不勾選記住裝置仍可正常登入。受信任的 Hub 管理監聽器例外,仍可記住裝置。
+**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie,以及另外存入本站 localStorage 的裝置證明。HTTPS 下 Cookie 使用 Secure,且僅限本主機。Cookie 和證明都不是管理員權杖,且都不能單獨使用。同一主機名稱的不同連接埠共用 Cookie;依來源隔離的證明將記住的裝置綁定至本儀表板來源。仍建議使用專用主機名稱。管理員權杖不存入 localStorage 或 sessionStorage。瀏覽器儲存空間無法使用或無法儲存證明時,記住裝置會回報無法使用。在終止 TLS 的反向代理後方,OpenCodex 看到的是一般 HTTP 請求,因此會拒絕為該位址記住裝置;登入對話框會說明原因並關閉該選項,不勾選記住裝置仍可正常登入。
私有 dashboard-sessions.json 儲存 Cookie、證明、管理員憑證和瀏覽器識別的雜湊、伺服器來源及固定30天有效期。重新開啟 Safari 主畫面應用程式時,有效裝置記錄可恢復短期工作階段。記住的工作階段無法核准需要同意的操作:GitHub 星號、重設額度消耗、提示詞層編輯、Meta Muse 登入、桌面相容性變更或配額驗證。這些操作請使用新的本機工作階段或已配對工作階段。中繼、跨來源和遠端明文 HTTP 不支援記住裝置。過期、瀏覽器識別變更,或更換管理員權杖並重新啟動伺服器後需重新登入。
From 83405eae573b4e046fd89f1361590181cfb3b528 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:37 +0900
Subject: [PATCH 12/14] Revert "fix(codex): probe the app-bundled CLI when no
Codex CLI resolves (#8395)"
This reverts commit 57cb4bb357df3d7e3c9f225de532959f99630c84.
---
.../content/docs/guides/codex-integration.md | 2 +-
.../docs/ko/guides/codex-integration.md | 2 +-
.../docs/zh-cn/guides/codex-integration.md | 2 +-
.../docs/zh-tw/guides/codex-integration.md | 2 +-
src/codex/catalog/bundled.ts | 37 +-------
structure/catalog.md | 2 +-
structure/codex-home.md | 2 +-
.../codex-runtime-macos-desktop.test.ts | 93 -------------------
8 files changed, 8 insertions(+), 134 deletions(-)
diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md
index 848e3da9c6c..0b75d49d6a4 100644
--- a/docs-site/src/content/docs/guides/codex-integration.md
+++ b/docs-site/src/content/docs/guides/codex-integration.md
@@ -445,7 +445,7 @@ $CODEX_HOME/opencodex-catalog.json
$CODEX_HOME/models_cache.json
```
-On macOS, opencodex checks the Codex app’s bundled CLI in `/Applications/ChatGPT.app` and then `~/Applications/ChatGPT.app` after explicit selections, shims, PATH, established installs, and the runnable bare `codex` fallback, requires the bundle’s XML `Info.plist` identity to be `com.openai.codex`, and never automatically replaces a runnable persisted selection with an app candidate. When no Codex CLI is found at all, the catalog probe behind `ocx connect status` readiness also tries those bundled locations, so a Child whose only Codex is the app’s bundled CLI reports its reasoning levels instead of `unverified`; every execution still passes the checks below.
+On macOS, opencodex checks the Codex app’s bundled CLI in `/Applications/ChatGPT.app` and then `~/Applications/ChatGPT.app` after explicit selections, shims, PATH, established installs, and the runnable bare `codex` fallback, requires the bundle’s XML `Info.plist` identity to be `com.openai.codex`, and never automatically replaces a runnable persisted selection with an app candidate.
Execution checks also recognize `Codex.app` and `ChatGPT.app` at any location, including relocated
bundles, explicit `CODEX_CLI_PATH` or configured runtime commands, and symlink aliases. A recognized
diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md
index e5adb56b101..9de4a3d9e7f 100644
--- a/docs-site/src/content/docs/ko/guides/codex-integration.md
+++ b/docs-site/src/content/docs/ko/guides/codex-integration.md
@@ -213,7 +213,7 @@ $CODEX_HOME/opencodex-catalog.json
$CODEX_HOME/models_cache.json
```
-macOS에서는 명시적 선택, shim, PATH, 기존 설치 경로와 실행 가능한 기본 `codex` 후보 뒤에 `/Applications/ChatGPT.app`, `~/Applications/ChatGPT.app` 순서로 Codex 앱의 번들 CLI를 확인하며, 번들의 XML `Info.plist` 식별자가 `com.openai.codex`여야 하고 실행 가능한 저장된 선택을 앱 후보로 자동 교체하지 않습니다. Codex CLI가 전혀 발견되지 않으면 `ocx connect status` 준비 상태 뒤의 카탈로그 프로브도 해당 번들 위치를 시도하므로, 앱 번들 CLI만 있는 Child는 `unverified` 대신 지원 추론 수준을 보고합니다. 실행 시에는 아래 검사가 그대로 적용됩니다.
+macOS에서는 명시적 선택, shim, PATH, 기존 설치 경로와 실행 가능한 기본 `codex` 후보 뒤에 `/Applications/ChatGPT.app`, `~/Applications/ChatGPT.app` 순서로 Codex 앱의 번들 CLI를 확인하며, 번들의 XML `Info.plist` 식별자가 `com.openai.codex`여야 하고 실행 가능한 저장된 선택을 앱 후보로 자동 교체하지 않습니다.
실행 시에는 위치와 관계없이 `Codex.app`과 `ChatGPT.app`을 인식하며, 이동한 번들, 명시적
`CODEX_CLI_PATH`·설정 런타임 명령, 심볼릭 링크 별칭에도 같은 검증을 적용합니다. 앱 이름은 검증
diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md
index e807acd44e9..05d5718e975 100644
--- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md
+++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md
@@ -149,7 +149,7 @@ $CODEX_HOME/opencodex-catalog.json
$CODEX_HOME/models_cache.json
```
-在 macOS 上,opencodex 在显式选择、shim、PATH、既有安装路径和可运行的裸 `codex` 后备命令之后,依次检查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 应用内置 CLI,要求其 XML `Info.plist` 标识为 `com.openai.codex`,且不会用应用候选项自动替换仍可运行的已保存选择。 如果完全没有发现 Codex CLI,则 `ocx connect status` 就绪状态背后的目录探测也会尝试这些内置位置,因此仅装有应用内置 CLI 的 Child 会报告其支持的推理级别,而不是 `unverified`;执行时仍会应用下述检查。
+在 macOS 上,opencodex 在显式选择、shim、PATH、既有安装路径和可运行的裸 `codex` 后备命令之后,依次检查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 应用内置 CLI,要求其 XML `Info.plist` 标识为 `com.openai.codex`,且不会用应用候选项自动替换仍可运行的已保存选择。
执行检查还会识别任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移动后的应用包、显式
`CODEX_CLI_PATH` 或配置的运行时命令,以及符号链接别名。名称只触发验证,不代表可信。
diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md
index 2d00f3344aa..e8eded5bcaf 100644
--- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md
+++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md
@@ -146,7 +146,7 @@ $CODEX_HOME/opencodex-catalog.json
$CODEX_HOME/models_cache.json
```
-在 macOS 上,opencodex 在明確選擇、shim、PATH、既有安裝路徑和可執行的裸 `codex` 後備命令之後,依序檢查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 應用程式內建 CLI,要求其 XML `Info.plist` 識別碼為 `com.openai.codex`,且不會用應用程式候選項自動取代仍可執行的已儲存選擇。 若完全找不到 Codex CLI,`ocx connect status` 就緒狀態背後的目錄探測也會嘗試這些內建位置,因此僅有應用程式內建 CLI 的 Child 會回報其支援的推理等級,而不是 `unverified`;執行時仍會套用下列檢查。
+在 macOS 上,opencodex 在明確選擇、shim、PATH、既有安裝路徑和可執行的裸 `codex` 後備命令之後,依序檢查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 應用程式內建 CLI,要求其 XML `Info.plist` 識別碼為 `com.openai.codex`,且不會用應用程式候選項自動取代仍可執行的已儲存選擇。
執行檢查也會辨識任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移動後的應用程式套件、明確的
`CODEX_CLI_PATH` 或設定的執行階段命令,以及符號連結別名。名稱只觸發驗證,不代表可信。
diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts
index 09178baa28e..8b2e88613d7 100644
--- a/src/codex/catalog/bundled.ts
+++ b/src/codex/catalog/bundled.ts
@@ -38,7 +38,6 @@ import { retainedNativeInheritanceTemplate } from "./native-template";
import type { RawCatalog, RawEntry } from "./parsing";
import { isSpawnableCodexCandidate } from "../exec-invocation";
import { validatedCodexExecInvocation, validatedCodexExecInvocationAsync } from "../exec-boundary";
-import { macCodexAppCommands } from "../mac-app-bundle";
import { isPlainObject } from "../../lib/plain-data";
import {
parsePersistedCodexRuntime,
@@ -52,7 +51,6 @@ import {
import type {
DeepReadonly,
EffortClampDiagnostic,
- ResolveCodexRuntimeResult,
ResolvedCodexRuntime,
} from "../runtime";
import type {
@@ -334,37 +332,6 @@ function bundledRuntimeDeps(deps: BundledCatalogDeps) {
};
}
-/**
- * Candidates for the bundled-catalog probe.
- *
- * When discovery finds no Codex CLI it answers with the bare `codex` sentinel, so the probe
- * asks a command that does not exist while a real CLI sits inside ChatGPT.app. Discovery only
- * offers that app candidate after classifying the bundle's XML `Info.plist` identity, so a
- * bundle it cannot classify (a binary plist, or an identity assertion that moved with an app
- * update) left `ocx connect status` reporting `unverified` on a macOS Child whose only Codex
- * was the bundled CLI (#8360).
- *
- * The known app locations are therefore added after a bare sentinel. This list grants no
- * trust: every execution still passes the app-bundle signature and ownership gate in
- * `exec-boundary.ts`, so an unverifiable bundle yields `null` rather than an invented ladder.
- * Windows needs no entry here because its Codex App install root under LOCALAPPDATA is already
- * enumerated by `installedCodexCandidates` in `runtime.ts`.
- */
-function bundledCatalogCandidates(
- deps: BundledCatalogDeps,
- resolved: ResolveCodexRuntimeResult | null,
-): string[] {
- const candidates = deps.commandCandidates?.() ?? [resolved!.runtime.command];
- // An explicit empty list asks for no probe at all.
- if (candidates.length === 0) return candidates;
- if ((deps.platform ?? process.platform) !== "darwin") return candidates;
- // Only the unresolved sentinel is a bare name; a PATH or installed selection is absolute, and
- // that selection is the runtime the operator is already running, not a gap to fill.
- if (candidates.some(command => command.includes("/") || command.includes("\\"))) return candidates;
- const exists = deps.existsSync ?? existsSync;
- return [...candidates, ...macCodexAppCommands(deps).filter(command => exists(command))];
-}
-
function* bundledCatalogSteps(
candidates: string[],
cacheKey: string | null,
@@ -423,7 +390,7 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): Readonly
const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.execFile && !deps.configDir && !deps.env;
const resolved = deps.commandCandidates ? null : resolveAndPersistCodexRuntime(bundledRuntimeDeps(deps));
const inputs = codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps));
- const steps = bundledCatalogSteps(bundledCatalogCandidates(deps, resolved),
+ const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command],
useCache ? bundledRuntimeKey(resolved!.runtime) : null,
() => inputs === codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps)));
let step = steps.next();
@@ -482,7 +449,7 @@ export function loadBundledCodexCatalogAsync(
const runtimeEpoch = codexRuntimeStateEpoch();
const current = () => active() && runtimeEpoch === codexRuntimeStateEpoch()
&& flight.inputs === codexRuntimeSelectionIdentity(runtimeDeps);
- const steps = bundledCatalogSteps(bundledCatalogCandidates(deps, resolved),
+ const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command],
useCache ? bundledRuntimeKey(resolved!.runtime) : null, current, false);
let step = steps.next();
while (!step.done) {
diff --git a/structure/catalog.md b/structure/catalog.md
index c0310c0aa7b..f9193633c27 100644
--- a/structure/catalog.md
+++ b/structure/catalog.md
@@ -96,7 +96,7 @@ Cache invalidation reports an unchanged derived cache separately from a failed r
Custom rows replace discovered rows that encode to the same Codex-facing slug. For a provider whose captured discovery declares an order (`preferFirst`), `src/codex/catalog/routed-gather.ts` puts the replacement in the discovered row's slot; every other provider keeps discovered rows first and custom rows after them, as before. Coverage: `tests/codex-integration/catalog-custom-ordering.test.ts`.
-On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. `src/codex/catalog/bundled.ts` treats unsupported bundled-model commands, spawn failures and structurally invalid model rows (null, arrays or other non-objects) as unavailable in both synchronous and asynchronous loading, preserving the existing catalog fallback. On macOS, when discovery ends on the bare `codex` sentinel because it found no CLI, the same loader also probes the known ChatGPT.app locations from `src/codex/mac-app-bundle.ts` before reporting unavailable (#8360), so `ocx connect status` readiness can report the bundled CLI's ladder when the bundle identity gate could not classify it; the candidate list grants no trust, and an unverifiable bundle still yields null because every execution passes the exec-boundary checks. This split is
+On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. `src/codex/catalog/bundled.ts` treats unsupported bundled-model commands, spawn failures and structurally invalid model rows (null, arrays or other non-objects) as unavailable in both synchronous and asynchronous loading, preserving the existing catalog fallback. This split is
required because empty or partial provider discovery must preserve routed entries and genuine user-native rows from the file that will be overwritten; a bundled catalog never contains those rows. Retained sync and evidence-bound convergence
share an explicit observed-state merge policy and resolve unbanded priorities through the [native source-priority contract](subagents.md#native-source-priority), including live/published/cache parity. A configured custom catalog remains the native
metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex
diff --git a/structure/codex-home.md b/structure/codex-home.md
index 0c7be676ee8..e7d2e319957 100644
--- a/structure/codex-home.md
+++ b/structure/codex-home.md
@@ -43,7 +43,7 @@ user off a Windows home they were running against, and a stat failure other than
local home rather than switching to a different one. Codex runtime discovery (src/codex/runtime.ts) also reads this home on Linux: after an explicit runtime, PATH, and the ordinary install locations, it enumerates the direct children of /bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly
set path that is unreadable or not a directory is an error, not a fallback: silently using a
different home than the operator named would write provider state where nobody is looking for it. A proven missing home (including explicit `CODEX_HOME`) resolves lexically; native restore, remove and journal reconcile succeed without creating a directory or acquiring the config write lock. Dangling links do not prove absence, and existing homes retain ownership checks. Lock-path resolution never creates parents; unresolved parents yield a typed, handled refusal. Scalar and multi-agent feature edits refuse missing homes; prompt commits and native feature commands retain their directory creation before locking. The management native feature toggle resolves and retains the spawn invocation before preparing a home; unavailable executables return the toggle failure without creating it. Failed native toggles and prompt commits keep a home they created (`src/codex/prepared-home.ts`): a pathname-based removal cannot prove it still names that directory, and an empty home is what Codex itself creates. A fresh install can have that directory but no `config.toml` yet; applying the integration then creates an empty `config.toml` there (never overwriting an existing file) and continues, while a missing home directory is refused with instructions to start Codex once or set `CODEX_HOME` (issue 5422).
-On macOS, `src/codex/runtime.ts` tries the system then user ChatGPT.app bundle after environment, persisted selection, shim, PATH, established installs and a runnable bare fallback, preferring `Contents/Resources/codex-cli/bin/codex` over `Contents/Resources/codex`. Bundle membership compares the normalized and canonical (symlink-resolved) forms of the path and bundle with a segment boundary, including backing executables; candidate ordering keys on the generated app commands, so an established install path that links into the app keeps its precedence. Discovery and probe prechecks require exactly one top-level `CFBundleIdentifier` equal to `com.openai.codex` in a regular XML `Contents/Info.plist`, read in-process with a 64 KiB limit; the whole document must be valid plist XML, so binary, malformed (anywhere, not only in the identity pair), oversized or unavailable metadata fails closed. App candidates never supersede a runnable persisted selection, including legacy/discovered records and the fast path, but full discovery can report them in `newerAvailable`; invalid selections retain `replacedConfigured` recovery. When a verified app candidate exists, deferred discovery skips the bare fallback as a candidate and retains it only as the no-candidate sentinel. When discovery ends on that sentinel because no Codex CLI was found, the bundled-catalog probe in `src/codex/catalog/bundled.ts` also tries the same app locations before reporting unavailable, so `ocx connect status` readiness reports the bundled CLI's ladder even when the identity gate could not classify the bundle (#8360); the probe grants no trust and every execution still passes the exec-boundary signature and ownership checks.
+On macOS, `src/codex/runtime.ts` tries the system then user ChatGPT.app bundle after environment, persisted selection, shim, PATH, established installs and a runnable bare fallback, preferring `Contents/Resources/codex-cli/bin/codex` over `Contents/Resources/codex`. Bundle membership compares the normalized and canonical (symlink-resolved) forms of the path and bundle with a segment boundary, including backing executables; candidate ordering keys on the generated app commands, so an established install path that links into the app keeps its precedence. Discovery and probe prechecks require exactly one top-level `CFBundleIdentifier` equal to `com.openai.codex` in a regular XML `Contents/Info.plist`, read in-process with a 64 KiB limit; the whole document must be valid plist XML, so binary, malformed (anywhere, not only in the identity pair), oversized or unavailable metadata fails closed. App candidates never supersede a runnable persisted selection, including legacy/discovered records and the fast path, but full discovery can report them in `newerAvailable`; invalid selections retain `replacedConfigured` recovery. When a verified app candidate exists, deferred discovery skips the bare fallback as a candidate and retains it only as the no-candidate sentinel.
`src/codex/home.ts` imports path expansion directly from `src/config/paths.ts`, so a fresh WSL process can resolve its Codex home without re-entering the config facade before the resolver initializes.
Provider-ID approvals are home-bound; see the [custom routing contract](codex-provider-routing.md). The managed files are:
diff --git a/tests/codex-integration/codex-runtime-macos-desktop.test.ts b/tests/codex-integration/codex-runtime-macos-desktop.test.ts
index a1ebf35ee8c..764eee42643 100644
--- a/tests/codex-integration/codex-runtime-macos-desktop.test.ts
+++ b/tests/codex-integration/codex-runtime-macos-desktop.test.ts
@@ -7,8 +7,6 @@ import {
setCodexRuntimeResolveCacheForTests, type ResolveCodexRuntimeDeps,
} from "../../src/codex/runtime";
import { loadBundledCodexCatalog, loadBundledCodexCatalogAsync } from "../../src/codex/catalog/bundled";
-import { codexSupportedReasoningEfforts } from "../../src/codex/catalog/effort";
-import { inspectClientCatalogReadiness } from "../../src/client/catalog-compatibility";
import { removeTreeWithRetry } from "../helpers/remove-tree";
const roots: string[] = [];
@@ -318,94 +316,3 @@ describe("bundled app catalog consumer", () => {
});
}
});
-
-/**
- * #8360: a macOS Child whose only Codex is the CLI inside ChatGPT.app reported
- * `readiness: unverified` ("the selected local Codex runtime did not report the reasoning
- * levels it supports"). The runtime resolver only offers the app candidate after it can
- * classify the bundle's XML `Info.plist` identity, so a bundle it cannot classify leaves the
- * selection on the bare `codex` sentinel. The readiness probe then asked a command that does
- * not exist while a real, trusted Codex sat inside the app.
- */
-describe("app-bundled CLI fallback for connect readiness (#8360)", () => {
- /** A catalog with a distinctive ladder, so a warm fixture value cannot pass by accident. */
- const bundledCatalog = JSON.stringify({ models: [{
- slug: "gpt-5.6-sol",
- base_instructions: "Fixture instructions.",
- supported_reasoning_levels: [{ effort: "low" }, { effort: "high" }],
- }] });
-
- /** Exec seam: only the bundled CLI answers, with its version or its bundled catalog. */
- function bundledOnlyExec(appCommand: string, spawned: string[]) {
- return (file: string, args: readonly string[]) => {
- spawned.push(file);
- if (file !== appCommand) throw Object.assign(new Error("not found"), { code: "ENOENT" });
- return args[0] === "debug" ? bundledCatalog : "codex-cli 0.166.0-alpha.6";
- };
- }
-
- test("an app-bundled-only layout yields the bundled ladder and reports ready", () => {
- const f = fixture();
- // The bundle identity is not classifiable (the shape an app update can leave behind), so
- // discovery declines the app candidate and the resolver ends on the bare sentinel.
- f.deps.readBundleIdentifier = () => null;
- f.versions.set(f.current, "0.166.0-alpha.6");
- const spawned: string[] = [];
- const exec = bundledOnlyExec(f.current, spawned);
- const selected = resolveCodexRuntime({ ...f.deps, discoverAlternatives: false, execFileSync: exec });
- expect(selected.runtime.command).toBe("codex");
-
- const efforts = codexSupportedReasoningEfforts({
- ...f.deps, execFileSync: exec, commandCandidates: () => [selected.runtime.command],
- });
- expect(efforts).toEqual(new Set(["low", "high"]));
- expect(inspectClientCatalogReadiness("present", bundledCatalog, { supportedEfforts: () => efforts }))
- .toEqual({ kind: "ready" });
- expect(spawned).toContain(f.current);
- });
-
- test("app locations that do not exist leave the ladder unverified instead of inventing one", () => {
- const f = fixture();
- f.deps.readBundleIdentifier = () => null;
- const spawned: string[] = [];
- const exec = bundledOnlyExec(f.current, spawned);
- const efforts = codexSupportedReasoningEfforts({
- ...f.deps, execFileSync: exec, commandCandidates: () => ["codex"],
- });
- expect(efforts).toBeNull();
- // No app binary exists, so none is offered; the bare sentinel cannot resolve at the exec
- // boundary either. A missing runtime stays unverified rather than borrowing a ladder.
- expect(spawned).toEqual([]);
- });
-
- test("a resolved absolute command is probed as given, without widening to app candidates", () => {
- const f = fixture();
- const standalone = "/fixture/bin/codex";
- f.versions.set(standalone, "0.167.0");
- f.versions.set(f.current, "0.166.0-alpha.6");
- const spawned: string[] = [];
- const exec = (file: string, args: readonly string[]) => {
- spawned.push(file);
- return args[0] === "debug" ? bundledCatalog : "codex-cli 0.167.0";
- };
- expect(codexSupportedReasoningEfforts({
- ...f.deps, execFileSync: exec, commandCandidates: () => [standalone],
- })).toEqual(new Set(["low", "high"]));
- expect(spawned).toEqual([standalone]);
- });
-
- test("the async bundled-catalog driver widens a bare sentinel the same way", async () => {
- const f = fixture();
- f.versions.set(f.current, "0.166.0-alpha.6");
- const spawned: string[] = [];
- const exec = bundledOnlyExec(f.current, spawned);
- const catalog = await loadBundledCodexCatalogAsync({
- ...f.deps,
- commandCandidates: () => ["codex"],
- execFileSync: exec as never,
- execFile: async (file, args) => exec(file, args),
- });
- expect(catalog?.models[0]?.slug).toBe("gpt-5.6-sol");
- expect(spawned).toContain(f.current);
- });
-});
From 1cb82a1cc1b8221226512d3a658a4af9ebd3b0ae Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:37 +0900
Subject: [PATCH 13/14] Revert "fix(quota): read Kimi Coding Plan usage from
the regional kimi.ai origin (#8399)"
This reverts commit 6ce65a5a449157c7bdb1fe479de84bb186302438.
---
.../src/content/docs/guides/providers.md | 4 +-
src/providers/quota/vendor-probes-key.ts | 27 ++--------
structure/providers-and-adapters.md | 2 +-
tests/providers/kimi-monthly-quota.test.ts | 52 ++-----------------
4 files changed, 10 insertions(+), 75 deletions(-)
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 0a47196a510..18dd72b84e5 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -1900,9 +1900,7 @@ Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, Command Code, and any a6api-
or Abliteration custom provider.
**Kimi Coding Plan quota.** Account login and API-key providers on the canonical
-`https://api.kimi.com/coding/v1` endpoint read `/usages`. API-key providers on the regional
-`https://api.kimi.ai/coding/v1` endpoint read `/usages` from that same origin; account login
-stays on `api.kimi.com`. Plans that report
+`https://api.kimi.com/coding/v1` endpoint read `/usages`. Plans that report
`usages.limit_month_total` (or `monthTotal`) show a monthly utilization bar, with
`limit_month_code` (or `monthCode`) as the fallback when total usage is unavailable.
The `used_ratio` / `usedRatio` fraction must be in `[0, 1]` before conversion to a consumed
diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts
index 9a258c0628d..e34d581d787 100644
--- a/src/providers/quota/vendor-probes-key.ts
+++ b/src/providers/quota/vendor-probes-key.ts
@@ -24,13 +24,7 @@ async function quotaFetch(providerName: string, config: OcxProviderConfig, url:
}
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
-/**
- * Kimi's regional (kimi.ai) Coding API. Its `/usages` answers a Coding Plan API key the same way
- * the kimi.com origin does (#8332). Only key auth may use it: the probe then calls the origin that
- * already receives this key for inference, so it adds no credential destination. OAuth tokens
- * stay on the kimi.com origin they were issued for.
- */
-const KIMI_CODE_REGIONAL_BASE_URL = "https://api.kimi.ai/coding/v1";
+const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`;
const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`;
@@ -1125,18 +1119,6 @@ export function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean {
return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL;
}
-/**
- * The Kimi Coding origin whose `/usages` a quota probe may call with this provider's bearer, or
- * null. It is always the configured inference origin, matched exactly against Kimi's own origins,
- * so a user-edited or lookalike host never receives the credential.
- */
-function kimiCodeQuotaBaseUrl(config: Pick): string | null {
- const normalized = normalizedBaseUrl(config.baseUrl);
- if (normalized === KIMI_CODE_BASE_URL) return normalized;
- if (normalized === KIMI_CODE_REGIONAL_BASE_URL && (config.authMode ?? "key") === "key") return normalized;
- return null;
-}
-
export function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean {
const normalized = normalizedBaseUrl(baseUrl);
// OAuth preset points at the API root; the Provider-API preset at /provider/v1.
@@ -1288,10 +1270,9 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: str
export async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise {
// Never release credentials to a user-edited or lookalike provider host.
- const baseUrl = kimiCodeQuotaBaseUrl(config);
- if (!baseUrl) return null;
+ if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null;
if (!accessToken) return null;
- const response = await quotaFetch(provider, config, `${baseUrl}/usages`, {
+ const response = await quotaFetch(provider, config, KIMI_CODE_USAGE_URL, {
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
redirect: "error",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
@@ -1454,7 +1435,7 @@ type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise {
const bearer = await resolveKimiQuotaBearer(config);
return bearer ? fetchKimiQuota(id, config, bearer) : null;
diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md
index 85fc49eaf63..2f2576e4cca 100644
--- a/structure/providers-and-adapters.md
+++ b/structure/providers-and-adapters.md
@@ -91,7 +91,7 @@ Kimi Coding's Chat, API-key, and optional Responses presets consume the same mod
The Responses preset shares the `kimi` OAuth account and Coding endpoint, keeps Chat as the featured default, and enables adjacent tool-result repair on its Responses wire. Its metadata alias is generated from the registry; sharing authentication does not implicitly share a usage-price namespace.
The separate `moonshot` API preset defaults to `kimi-k3.1-pro`; `src/providers/model-rename-migration.ts` moves discontinued K2.5 selections on official destinations to it, drops stale no-reasoning classification, and refreshes exact old 256K/empty reasoning seeds to 1M and `low`/`high`/`max` with the API `none` omission map. Explicit successor metadata and Custom destinations keep precedence. Roster repair accepts the literal pre-K3.1 seed and its post-rename form, with metadata additions measured against the original seed. K2.6 and K2.7 Code/highspeed stay seeded for existing API users.
-Kimi quota in `src/providers/quota/vendor-probes-key.ts` also reads monthly `usages` at the root, under `data`, or under `quota` (including `data.quota`): `limit_month_total` / `monthTotal` precedes the `limit_month_code` / `monthCode` fallback. Containers are tried in root, `data`, `quota`, then `data.quota` order until a monthly row parses; empty or unusable earlier containers do not mask later data, and monthly lookup retains the original envelope even when legacy windows unwrap `data`. Rows prefer limit/used or remaining arithmetic, then fractional `used_ratio` / `usedRatio` in `[0, 1]`, then direct percentages; out-of-range ratios fall through and reset times remain optional. Missing monthly data stays absent, and legacy 5-hour, weekly and total subscription-credit windows stay separate. The probe calls `/usages` on the configured inference origin only when it is exactly `https://api.kimi.com/coding/v1`, or `https://api.kimi.ai/coding/v1` for key auth (#8332), so the bearer never reaches a host it was not already sent to; account-login tokens stay on `api.kimi.com`. The parser is shared by account-login and API-key probes; credential and destination guards are unchanged.
+Kimi quota in `src/providers/quota/vendor-probes-key.ts` also reads monthly `usages` at the root, under `data`, or under `quota` (including `data.quota`): `limit_month_total` / `monthTotal` precedes the `limit_month_code` / `monthCode` fallback. Containers are tried in root, `data`, `quota`, then `data.quota` order until a monthly row parses; empty or unusable earlier containers do not mask later data, and monthly lookup retains the original envelope even when legacy windows unwrap `data`. Rows prefer limit/used or remaining arithmetic, then fractional `used_ratio` / `usedRatio` in `[0, 1]`, then direct percentages; out-of-range ratios fall through and reset times remain optional. Missing monthly data stays absent, and legacy 5-hour, weekly and total subscription-credit windows stay separate. The parser is shared by account-login and API-key probes; credential and destination guards are unchanged.
OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its
64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow
diff --git a/tests/providers/kimi-monthly-quota.test.ts b/tests/providers/kimi-monthly-quota.test.ts
index 2c6f109beaf..e48d75f6c4a 100644
--- a/tests/providers/kimi-monthly-quota.test.ts
+++ b/tests/providers/kimi-monthly-quota.test.ts
@@ -35,7 +35,7 @@ afterEach(() => {
removeTreeWithRetry(home);
});
-function config(authMode: "key" | "oauth" = "key", baseUrl = "https://api.kimi.com/coding/v1"): OcxConfig {
+function config(authMode: "key" | "oauth" = "key"): OcxConfig {
const name = authMode === "oauth" ? "kimi" : "kimi-code";
return {
defaultProvider: name,
@@ -43,16 +43,16 @@ function config(authMode: "key" | "oauth" = "key", baseUrl = "https://api.kimi.c
[name]: {
adapter: "openai-chat",
authMode,
- baseUrl,
+ baseUrl: "https://api.kimi.com/coding/v1",
...(authMode === "key" ? { apiKey: "kimi-monthly-fixture-key" } : {}),
},
},
} as OcxConfig;
}
-function mockUsage(payload: unknown, url = "https://api.kimi.com/coding/v1/usages"): void {
+function mockUsage(payload: unknown): void {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
- expect(String(input)).toBe(url);
+ expect(String(input)).toBe("https://api.kimi.com/coding/v1/usages");
expect(init?.redirect).toBe("error");
return Response.json(payload);
}) as typeof fetch;
@@ -316,47 +316,3 @@ describe("Kimi monthly quota", () => {
expect(reports[0]?.quota.customWindows).toEqual([{ label: "Total subscription credits", percent: 60 }]);
});
});
-
-describe("Kimi regional (kimi.ai) Coding Plan quota (#8332)", () => {
- const regional = "https://api.kimi.ai/coding/v1";
-
- test("a key-auth provider on api.kimi.ai probes its own origin's /usages", async () => {
- // Field shape from public API-key reports against this endpoint: legacy counters beside
- // ratio windows that can contradict them. Valid counters win over a zero ratio.
- mockUsage({
- usage: { limit: 100, used: 40, remaining: 60, resetTime: resetAt },
- limits: [{ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, detail: { limit: 100, used: 100, remaining: 0, resetTime: resetAt } }],
- usages: { limit_5h: { used_ratio: 0 }, limit_7d: { used_ratio: 0 }, limit_month_total: { used_ratio: 0.1, reset_time: resetAt } },
- }, `${regional}/usages`);
-
- const { reports } = await fetchProviderQuotaReports(config("key", `${regional}/`), true);
-
- expect(reports).toHaveLength(1);
- expect(reports[0]?.source).toBe("kimi:usages");
- expect(reports[0]?.quota).toMatchObject({ fiveHourPercent: 100, weeklyPercent: 40, monthlyPercent: 10 });
- });
-
- test.each([
- { name: "an OAuth token on api.kimi.ai", authMode: "oauth" as const, baseUrl: regional },
- { name: "a lookalike host", authMode: "key" as const, baseUrl: "https://api.kimi.ai.example.test/coding/v1" },
- { name: "another path on api.kimi.ai", authMode: "key" as const, baseUrl: "https://api.kimi.ai/v1" },
- ])("$name never receives the credential", async ({ authMode, baseUrl }) => {
- if (authMode === "oauth") {
- await saveCredential("kimi", {
- access: "kimi-regional-fixture-access",
- refresh: "kimi-regional-fixture-refresh",
- expires: Date.now() + 3600_000,
- });
- }
- const requested: string[] = [];
- globalThis.fetch = (async (input: RequestInfo | URL) => {
- requested.push(String(input));
- return Response.json({ usages: { limit_month_total: monthlyRow } });
- }) as typeof fetch;
-
- const { reports } = await fetchProviderQuotaReports(config(authMode, baseUrl), true);
-
- expect(requested.filter(url => url.includes("/usages"))).toEqual([]);
- expect(reports.some(report => report.source === "kimi:usages")).toBe(false);
- });
-});
From 89fe4bb9e886bef877a325657b0279cbc793129b Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:50:37 +0900
Subject: [PATCH 14/14] Revert "feat(link): sync route intent and materialize
clients per device (#8274) (#8276)"
This reverts commit 83c9c3e6c65722268dda1fdeb7b2812b7eaf9463.
---
.../src/content/docs/guides/integrations.md | 2 -
.../src/content/docs/guides/remote-link.md | 41 --
.../content/docs/ko/guides/integrations.md | 2 -
.../src/content/docs/ko/guides/remote-link.md | 41 --
native/ocx-child/src/parity.rs | 6 +-
scripts/test-layout/layout-route-intent.json | 60 ---
scripts/test-layout/layout.json | 2 +-
.../ocx/references/01_management_surface.md | 18 +-
.../references/01_surface_access-remote.md | 6 +-
.../references/01_surface_agents-routing.md | 68 +--
.../ocx/references/01_surface_integrations.md | 8 +-
skills/ocx/references/01_surface_lifecycle.md | 4 +-
src/claude/desktop-3p.ts | 16 +-
src/claude/desktop-gateway-state.ts | 2 -
src/claude/desktop-remote-store-artifact.ts | 17 +-
src/claude/desktop-remote-store.ts | 23 +-
src/cli/capabilities-access-remote.ts | 5 +-
src/cli/capabilities-base.ts | 10 +-
src/cli/capabilities-integrations.ts | 4 +-
src/cli/capabilities-route-to.ts | 41 --
src/cli/capabilities.ts | 2 -
src/cli/claude-desktop.ts | 41 +-
src/cli/connect.ts | 34 +-
src/cli/dispatch.ts | 26 +-
src/cli/help-catalog.ts | 11 -
src/cli/help.ts | 2 +-
src/cli/inspect.ts | 13 +-
src/cli/integrations.ts | 27 +-
src/cli/link.ts | 20 +-
src/cli/registry.ts | 8 +-
src/cli/route-to.ts | 169 -------
src/cli/sync-command.ts | 26 +-
src/cli/sync-fanout.ts | 6 -
src/cli/sync-flags.ts | 12 +-
src/client/connect.ts | 234 +---------
src/client/link-home.ts | 96 +---
src/client/link-join.ts | 12 +-
src/client/link-parity-reporter.ts | 66 +--
src/client/route-intent-apply.ts | 293 ------------
src/client/route-intent-file-originals.ts | 97 ----
src/client/route-intent-native-originals.ts | 201 ---------
src/client/route-intent-watch.ts | 141 ------
src/client/runtime.ts | 14 -
src/client/sync-runtime.ts | 7 -
src/codex/journal-config-restore.ts | 217 ---------
src/codex/journal.ts | 31 +-
src/config/schema/leaf-validators.ts | 5 +-
src/integrations/route-intent-catalog.ts | 26 --
src/integrations/route-intent-connected.ts | 167 -------
src/integrations/route-intent-contract.ts | 55 ---
src/integrations/route-intent-disconnect.ts | 102 -----
src/integrations/route-intent-initial.ts | 64 ---
src/integrations/route-intent-state.ts | 86 ----
src/integrations/route-intent-tick.ts | 65 ---
src/integrations/route-intent.ts | 209 ---------
src/link/child-parity.ts | 27 +-
src/link/management-routes.ts | 4 +-
src/link/remote-windows.ts | 22 +-
src/link/route-intent-watch.ts | 41 --
src/link/route-intent.ts | 209 ---------
src/link/ssh-argv.ts | 27 +-
src/routing/home-route-capture.ts | 123 -----
src/routing/home-route-intent.ts | 178 --------
src/routing/route-intent-state.ts | 178 --------
src/routing/route-to-live-settings.ts | 35 --
src/routing/route-to-local.ts | 88 ----
src/routing/route-to-status.ts | 81 ----
src/routing/route-to-sync-child.ts | 32 --
src/server/index/link-listener.ts | 41 --
src/server/index/link-management.ts | 16 +-
src/server/index/route-intent-watch.ts | 47 --
src/server/index/serve-options.ts | 1 -
src/server/management-api.ts | 7 -
.../management/agent-settings-routes.ts | 14 +-
.../management/home-integration-decision.ts | 89 ----
src/server/management/integration-routes.ts | 64 +--
src/server/management/link-enrollment.ts | 4 -
src/server/management/link-route-state.ts | 2 -
src/server/management/link-routes.ts | 9 +-
.../management/native-integration-routes.ts | 246 +++-------
src/server/management/protocol-routes.ts | 7 -
src/server/management/route-registry.ts | 3 -
src/server/management/route-to-routes.ts | 235 ----------
src/service/disconnected-client-restart.ts | 50 ---
src/service/repair.ts | 71 +--
src/types/config.ts | 2 +-
structure/cli-management.md | 6 +-
structure/clients/integrations.md | 10 +-
structure/codex-home.md | 2 +-
structure/config.md | 2 +-
structure/gui-and-management-api.md | 4 +-
structure/ops/service-and-sidecars.md | 2 +-
structure/remote-link.md | 35 +-
structure/runtime.md | 2 +-
.../ci-workflows/skill-ocx-generated.test.ts | 2 +-
.../claude-config-root-writer-barrier.test.ts | 32 --
.../claude-intercept-acl-barrier.test.ts | 4 -
tests/cli/cli-capabilities.test.ts | 11 -
tests/cli/cli-capability-data.test.ts | 8 -
tests/cli/cli-help-paths.test.ts | 8 +-
tests/cli/cli-link-route-intent.test.ts | 24 -
tests/cli/cli-registry.test.ts | 2 +-
tests/cli/cli-route-to-aliases.test.ts | 84 ----
tests/cli/cli-route-to-confirmation.test.ts | 36 --
tests/cli/cli-route-to.test.ts | 236 ----------
tests/cli/cli-storage-inspect.test.ts | 6 +-
tests/cli/cli-sync-expect-link.test.ts | 8 -
tests/cli/cli-sync-fanout-ephemeral.test.ts | 49 --
tests/clients/client-connect.test.ts | 6 +-
.../client-disconnect-config-recovery.test.ts | 154 -------
.../client-disconnect-edited-codex.test.ts | 97 ----
.../client-disconnect-field-snapshot.test.ts | 79 ----
tests/clients/client-home-unreachable.test.ts | 6 -
.../clients/home-route-intent-upgrade.test.ts | 154 -------
tests/clients/link-home-route-intent.test.ts | 48 --
tests/clients/link-parity-reporter.test.ts | 1 -
.../link-pinned-sync-overwrite.test.ts | 83 ----
tests/clients/route-intent-aside.test.ts | 84 ----
.../route-intent-claude-surface.test.ts | 35 --
...oute-intent-codex-connect-disabled.test.ts | 294 ------------
.../route-intent-codex-transitions.test.ts | 229 ----------
.../route-intent-connect-recovery.test.ts | 170 -------
.../route-intent-connected-review.test.ts | 162 -------
tests/clients/route-intent-connected.test.ts | 423 ------------------
tests/clients/route-intent-desktop.test.ts | 72 ---
.../route-intent-file-originals.test.ts | 113 -----
.../clients/route-intent-home-consent.test.ts | 123 -----
.../route-intent-local-receipt.test.ts | 25 --
.../clients/route-intent-materializer.test.ts | 191 --------
.../route-intent-native-originals.test.ts | 144 ------
.../route-intent-native-review.test.ts | 270 -----------
.../route-intent-offline-mode-switch.test.ts | 159 -------
tests/clients/route-intent-overwrite.test.ts | 40 --
.../route-intent-parity-fallback.test.ts | 101 -----
.../route-intent-parity-withdrawal.test.ts | 94 ----
tests/clients/route-intent-parity.test.ts | 29 --
tests/clients/route-intent-reporter.test.ts | 69 ---
.../clients/route-intent-runtime-stop.test.ts | 70 ---
tests/clients/route-intent-scheduler.test.ts | 43 --
tests/clients/route-intent-state.test.ts | 32 --
.../clients/route-intent-watch-client.test.ts | 204 ---------
tests/clients/route-intent.test.ts | 54 ---
.../codex-disconnect-fields.test.ts | 78 ----
.../codex-disconnect-owned-recovery.test.ts | 51 ---
...x-journal-provider-reserialization.test.ts | 75 ----
.../native-claude-code-toggle.test.ts | 2 -
.../native-codex-toggle.test.ts | 20 -
tests/fixtures/test-layout-expected.json | 7 +-
tests/helpers/cli-capability-data.ts | 3 -
tests/providers/zcode-client.test.ts | 20 +-
tests/routing/route-to-child-defaults.test.ts | 250 -----------
tests/routing/route-to-home-decisions.test.ts | 170 -------
.../routing/route-to-home-live-flags.test.ts | 211 ---------
.../route-to-home-native-truth.test.ts | 188 --------
.../routing/route-to-home-off-review.test.ts | 66 ---
tests/routing/route-to-sync-child.test.ts | 37 --
.../link-enrollment-ephemeral-child.test.ts | 6 +-
tests/server/link-enrollment.test.ts | 16 -
tests/server/link-listener-lifecycle.test.ts | 55 ---
tests/server/link-route-intent.test.ts | 58 ---
...agement-route-intent-native-errors.test.ts | 226 ----------
...agement-route-intent-native-review.test.ts | 62 ---
.../server/management-route-registry.test.ts | 5 -
.../route-intent-watch-boundary.test.ts | 123 -----
.../server/route-intent-watch-waiters.test.ts | 30 --
tests/server/route-to-ephemeral.test.ts | 89 ----
tests/server/route-to-live-settings.test.ts | 52 ---
tests/server/route-to-policy-review.test.ts | 88 ----
.../server/route-to-retirement-review.test.ts | 79 ----
tests/server/route-to-routes.test.ts | 180 --------
...ervice-disconnected-client-restart.test.ts | 136 ------
...service-restart-registration-guard.test.ts | 100 -----
.../service-scheduler-restart-cleanup.test.ts | 99 ----
173 files changed, 246 insertions(+), 11681 deletions(-)
delete mode 100644 scripts/test-layout/layout-route-intent.json
delete mode 100644 src/cli/capabilities-route-to.ts
delete mode 100644 src/cli/route-to.ts
delete mode 100644 src/client/route-intent-apply.ts
delete mode 100644 src/client/route-intent-file-originals.ts
delete mode 100644 src/client/route-intent-native-originals.ts
delete mode 100644 src/client/route-intent-watch.ts
delete mode 100644 src/codex/journal-config-restore.ts
delete mode 100644 src/integrations/route-intent-catalog.ts
delete mode 100644 src/integrations/route-intent-connected.ts
delete mode 100644 src/integrations/route-intent-contract.ts
delete mode 100644 src/integrations/route-intent-disconnect.ts
delete mode 100644 src/integrations/route-intent-initial.ts
delete mode 100644 src/integrations/route-intent-state.ts
delete mode 100644 src/integrations/route-intent-tick.ts
delete mode 100644 src/integrations/route-intent.ts
delete mode 100644 src/link/route-intent-watch.ts
delete mode 100644 src/link/route-intent.ts
delete mode 100644 src/routing/home-route-capture.ts
delete mode 100644 src/routing/home-route-intent.ts
delete mode 100644 src/routing/route-intent-state.ts
delete mode 100644 src/routing/route-to-live-settings.ts
delete mode 100644 src/routing/route-to-local.ts
delete mode 100644 src/routing/route-to-status.ts
delete mode 100644 src/routing/route-to-sync-child.ts
delete mode 100644 src/server/index/route-intent-watch.ts
delete mode 100644 src/server/management/home-integration-decision.ts
delete mode 100644 src/server/management/route-to-routes.ts
delete mode 100644 src/service/disconnected-client-restart.ts
delete mode 100644 tests/cli/cli-link-route-intent.test.ts
delete mode 100644 tests/cli/cli-route-to-aliases.test.ts
delete mode 100644 tests/cli/cli-route-to-confirmation.test.ts
delete mode 100644 tests/cli/cli-route-to.test.ts
delete mode 100644 tests/cli/cli-sync-fanout-ephemeral.test.ts
delete mode 100644 tests/clients/client-disconnect-config-recovery.test.ts
delete mode 100644 tests/clients/client-disconnect-edited-codex.test.ts
delete mode 100644 tests/clients/client-disconnect-field-snapshot.test.ts
delete mode 100644 tests/clients/home-route-intent-upgrade.test.ts
delete mode 100644 tests/clients/link-home-route-intent.test.ts
delete mode 100644 tests/clients/link-pinned-sync-overwrite.test.ts
delete mode 100644 tests/clients/route-intent-aside.test.ts
delete mode 100644 tests/clients/route-intent-claude-surface.test.ts
delete mode 100644 tests/clients/route-intent-codex-connect-disabled.test.ts
delete mode 100644 tests/clients/route-intent-codex-transitions.test.ts
delete mode 100644 tests/clients/route-intent-connect-recovery.test.ts
delete mode 100644 tests/clients/route-intent-connected-review.test.ts
delete mode 100644 tests/clients/route-intent-connected.test.ts
delete mode 100644 tests/clients/route-intent-desktop.test.ts
delete mode 100644 tests/clients/route-intent-file-originals.test.ts
delete mode 100644 tests/clients/route-intent-home-consent.test.ts
delete mode 100644 tests/clients/route-intent-local-receipt.test.ts
delete mode 100644 tests/clients/route-intent-materializer.test.ts
delete mode 100644 tests/clients/route-intent-native-originals.test.ts
delete mode 100644 tests/clients/route-intent-native-review.test.ts
delete mode 100644 tests/clients/route-intent-offline-mode-switch.test.ts
delete mode 100644 tests/clients/route-intent-overwrite.test.ts
delete mode 100644 tests/clients/route-intent-parity-fallback.test.ts
delete mode 100644 tests/clients/route-intent-parity-withdrawal.test.ts
delete mode 100644 tests/clients/route-intent-parity.test.ts
delete mode 100644 tests/clients/route-intent-reporter.test.ts
delete mode 100644 tests/clients/route-intent-runtime-stop.test.ts
delete mode 100644 tests/clients/route-intent-scheduler.test.ts
delete mode 100644 tests/clients/route-intent-state.test.ts
delete mode 100644 tests/clients/route-intent-watch-client.test.ts
delete mode 100644 tests/clients/route-intent.test.ts
delete mode 100644 tests/codex-integration/codex-disconnect-fields.test.ts
delete mode 100644 tests/codex-integration/codex-disconnect-owned-recovery.test.ts
delete mode 100644 tests/codex-integration/codex-journal-provider-reserialization.test.ts
delete mode 100644 tests/routing/route-to-child-defaults.test.ts
delete mode 100644 tests/routing/route-to-home-decisions.test.ts
delete mode 100644 tests/routing/route-to-home-live-flags.test.ts
delete mode 100644 tests/routing/route-to-home-native-truth.test.ts
delete mode 100644 tests/routing/route-to-home-off-review.test.ts
delete mode 100644 tests/routing/route-to-sync-child.test.ts
delete mode 100644 tests/server/link-route-intent.test.ts
delete mode 100644 tests/server/management-route-intent-native-errors.test.ts
delete mode 100644 tests/server/management-route-intent-native-review.test.ts
delete mode 100644 tests/server/route-intent-watch-boundary.test.ts
delete mode 100644 tests/server/route-intent-watch-waiters.test.ts
delete mode 100644 tests/server/route-to-ephemeral.test.ts
delete mode 100644 tests/server/route-to-live-settings.test.ts
delete mode 100644 tests/server/route-to-policy-review.test.ts
delete mode 100644 tests/server/route-to-retirement-review.test.ts
delete mode 100644 tests/server/route-to-routes.test.ts
delete mode 100644 tests/service/service-disconnected-client-restart.test.ts
delete mode 100644 tests/service/service-restart-registration-guard.test.ts
delete mode 100644 tests/service/service-scheduler-restart-cleanup.test.ts
diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index c08ebb07992..2463c8263ac 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -33,8 +33,6 @@ file, and removes it again. Each client below has its own switch:
| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback |
| T3 Code | `$T3CODE_HOME/userdata/settings.json` (default `~/.t3/userdata/settings.json`) | JSON | immediately — T3 Code watches the file | non-secret loopback marker |
-From the CLI, `ocx integration client enable|disable --client ` changes this machine only, like the dashboard toggle. On a Remote Link Home that uses route intent, the change is recorded as the Home's own decision, so `ocx sync` keeps it, and linked Children keep their defaults. To change what Children inherit, use `ocx route to on|off`. On a Remote Link Child a plain toggle is refused and prints the Home command for that device; use `ocx route to off --local` there to opt that Child out. See [Remote Link](/guides/remote-link/#route-clients-per-device).
-
## Gentle Shell
Choose **Connect → Gentle Shell**, or use the same managed-integration commands:
diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md
index 65e7f7b7965..383f73c8fa9 100644
--- a/docs-site/src/content/docs/guides/remote-link.md
+++ b/docs-site/src/content/docs/guides/remote-link.md
@@ -107,45 +107,6 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C
To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.`
-## Route clients per device
-
-The Home owns which clients route through OpenCodex. Each Child materializes that intent using its own installed clients, filesystem paths, listener port and served model catalog. No client config file or credential is copied from the Home. Device overrides take precedence over Home defaults; the built-in default enables Codex and Claude Code and leaves other clients off.
-
-On the Home itself, Codex, Claude Code, Grok and Claude Desktop follow the Home's own on/off switches, the same ones `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, turning the Messages API off, agent settings and the dashboard toggles change. Only an explicit `false` turns one off; an unset switch is on. Home defaults and device overrides never decide these four clients on the Home. An unset or enabled Grok or Claude Desktop switch that no route write is deciding does only what startup does: Grok follows the startup sync rules, and Claude Desktop refreshes a gateway profile OpenCodex already owns but never creates one or changes the Desktop mode. An owned profile that was changed since OpenCodex wrote it is reported as a conflict instead of being refreshed. A Desktop library OpenCodex never owned is left alone and reported as current, so a full sync does not fail on it. Run `ocx route to claude-desktop on --device home` (or `ocx route to claude-desktop on`) to create the Desktop profile. None of this enables Grok or Claude Desktop on Child devices.
-
-Children never read the Home's switches. A Child takes its device override, then the saved Home default, then the built-in default. The Home saves the defaults for these four clients once: Codex and Claude Code as the Home's switches stand at that moment (on unless explicitly off), Grok and Claude Desktop off. A Home default write (`ocx route to on|off` without `--device`) and enrolling a Child always save them. Once the Home has a link, they are also saved before anything Home-only can change a switch: when the link listener starts, and in `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, turning the Messages API off, the Claude toggle, the native toggles and a `--device home` route write. Before a Home-only integration change (a dashboard toggle, `ocx integration client enable|disable`, an undo from the integration history or an Aside profile change) a linked Home saves every default its Children inherit, file integrations included, and the link listener does the same when it starts, so a Home upgraded from a version that saved only these four freezes the rest before anything moves them. A link to an ephemeral `ocx-child` does not count here: it never reads route intent, so a Home whose only links are ephemeral has no Child to protect. On a Home with no link these Home-only commands save no default, so a Child enrolled later inherits the switches as they stand when it enrolls, unless a Home default write already saved the defaults. If such a Home already uses route intent because an earlier default write saved defaults, a Home-only integration change is still recorded as the Home's own row so `ocx sync` keeps it; Children never inherit that row. If saving fails when the link listener starts (for example, another process holds the configuration lock), it keeps retrying in the background. Reading route intent, through `ocx route to status` or by a Child, never saves anything. After that, what Children inherit changes only through a Home default write. Those Home-only commands change the Home alone. If the saved route intent file (`route-intent.json` in the OpenCodex config directory) cannot be read, these commands on a linked Home, and every `ocx route to` change, refuse without changing anything and name the file to fix or remove. On a Home with no link the native toggles, `ocx restore` and Home-only integration changes still work: an integration change then records nothing, and `ocx sync`, which cannot read the file either, cannot undo it.
-
-When `ocx route to on|off --device home` decides Codex, Claude Code, Grok or Claude Desktop, it saves that client's switch and then applies it. If applying ends in a conflict or failure (for example an external Codex provider, an incomplete Grok block or a changed Claude Desktop profile), the command fails and says whether the switch was saved: "The Home's grok switch was saved, but applying it ended in conflict" means the switch changed, and "The Home did not apply" means nothing changed. Claude Desktop in first-party mode refuses before saving.
-
-Once this Home uses route intent (it has a saved default or a Home override), `ocx sync` applies it to every client on the Home. So each Home-only integration change is recorded as this Home's decision for that client and `ocx sync` keeps it: a dashboard toggle, `ocx integration client enable|disable` (the same Home-only decision; `ocx route to on|off` changes what Children inherit), an undo from the integration history, and an Aside profile change, which is recorded as the stable Aside userIds of every profile the Home routes afterwards. Linked Children keep their defaults. When an Aside profile has no stable userId in Aside's `accounts.json`, an Aside profile change on such a Home is refused before anything changes (`route_intent_aside_unresolved`); repair the manifest, or decide Aside for the Home with `ocx route to aside on|off --device home`.
-
-```sh
-ocx route to status --device all --json
-ocx route to aside on --accounts all
-ocx route to aside on --device my-child --accounts stable-aside-user-id
-ocx route to pi off --device my-child
-ocx route to pi inherit --device my-child
-ocx route to sync --device my-child
-```
-
-Status JSON uses `ocx.route-to/1` and distinguishes desired intent, its source, and the last applied outcome. The source is `builtin`, `default`, `device` or `local` (a Child opt-out); the Home's rows for Codex, Claude Code, Grok and Claude Desktop report `native`, meaning the Home's own switch. `not_installed`, `conflict` and `failed` remain visible; a changed intent makes any older outcome `stale`, including an earlier conflict or failure, so a new decision is not judged by the result of the one it replaced. A missing receipt is not evidence of a successful apply. Interrupted or locally opted-out applies withdraw the previous Home confirmation. A client-specific command reports failures for that client and warns about other unresolved clients; explicit sync reports any unresolved failure and exits nonzero when intent or catalog discovery is deferred, even when cached off clients are already clean. Conflicts require an explicit `--overwrite-conflict` for that apply. When an apply fails unexpectedly after a write saved route policy or the Home's switch, the error says so and carries `desiredCommitted: true`.
-
-Without `--device`, a Home write changes the Home default and applies only that client locally through the same controller. For Codex, Claude Code, Grok and Claude Desktop it applies by writing the Home's switch, so `ocx route to codex off` turns Codex off on the Home and changes the default Children inherit. `--device home` (or `this` on the Home) decides the Home alone: for those four clients it writes only the Home's switch and leaves the Children's default unchanged, and `inherit` sets the Home's switch to the current default (the built-in default until one is saved); for other clients it records a Home override. A route write for another client, `ocx route to sync` and a plain `ocx sync` on the Home never change the Home's native switches. They only refresh what each switch already says, so they do not undo `ocx restore`, `ocx restore back`, turning the Messages API off or a dashboard toggle. Children inherit on their next sync tick. Explicit Home sync uses the existing identity-pinned SSH sync for Home-initiated links. With `--overwrite-conflict` (in `ocx route to` or `ocx sync`), Home runs that sync only on a Child whose OpenCodex is 2.100.0 or later, because an older one ignores the flag and would report a plain sync as success; any other Child is skipped with `child-ocx-lacks-overwrite`. A Child-initiated link keeps a bounded authenticated read watch; Home sync wakes it immediately and reports `pending` until the Child publishes an apply report. Without an active watcher, the result explicitly waits for the next tick. Conflict overwrite on a Child-initiated link requires the person at that Child to run `ocx route to sync --overwrite-conflict`; a wakeup grants no takeover permission. Local `ocx route to sync` always applies immediately.
-
-Aside `--accounts all` selects every account-backed profile on that device; `none` selects none. A list matches Aside **userId**, never numeric `u/N` slots, so different local slot layouts are safe. Model IDs are rendered from the device's catalog. Toggles preserve omitted account/model-name policy. Owned profiles missing from the Aside account manifest remain incomplete until their ownership is resolved; an unmanaged off client needs no account manifest.
-
-On a Child, `status` and `sync` work locally. Home-owned writes print the exact command to run on the Home. A person at the Child can opt out with `ocx route to aside off --local`; `ocx route to aside inherit --local` drops that opt-out. Local `on` is refused. The opt-out is reported as `source: local`. Home intent also wins over native commands run on a linked Child: `ocx restore` there turns Codex routing off only until the next sync reapplies the Home intent, about a minute later. Use `ocx route to codex off --local` to keep Codex off on that Child. A Child reconciles once a minute; each apply withdraws its previous confirmation before changing anything, so a status report sent during an apply can briefly show the Child as not applied until the next report.
-
-Existing owned integrations become Home defaults when migrating; unowned blocks are never inferred as consent. The native switch defaults are saved as described above, before any Home switch changes. Enrollment retains an explicit `connect --clients` selection as the initial device override; without an explicit selection, the Child inherits Home defaults and later changes. A subsequent Home device `on`, `off` or `inherit` supersedes that client's initial selection. No Child report changes Home policy. Native toggles, in the dashboard and through `ocx integrations native on|off`, change the Home's switch and run that client's own transition (including Claude Desktop's first-party switch), preserving Child defaults; they record no route intent for the Home. A Home override for one of these four clients saved by an earlier version is ignored and removed on the next route change. On a Child the CLI refuses and prints the Home command for that device. Retired device overrides are removed with their links. Claude Desktop first-party mode is preserved. It already routes through OpenCodex, so an enabled intent reports `current` without writing anything. An off intent reports a conflict naming the mode switch to run: `ocx integrations native claude-desktop off` on the Home, or `ocx claude desktop apply --gateway` on a Child followed by `ocx route to sync`.
-
-A Home write that decides a client the Home then cannot apply fails. `ocx route to claude-desktop off` on a first-party Home, with or without `--device home`, answers 409, reports that client's conflict and names the mode switch, and the CLI exits nonzero; the same happens when the Home's switch cannot be written. A default write still saves the Children's default.
-
-While linked, a Child writes its Home's effective intent into its own on/off switches for Codex, Claude Code, Claude Desktop and Grok; turning Claude Code off can also pin the Messages API setting. Before the first such change, the Child records the value each switch had on its own, including a switch that was never set. `ocx disconnect`, and an enrollment that fails, put those values back. If a failed enrollment cannot put them back, it keeps its service token and names the record it could not restore; fix the reported problem, then run `ocx disconnect`, which restores the switches and removes that token even though the machine is not connected. A switch you changed yourself while linked keeps your value. One limit: a switch you set to exactly the value the Home last applied cannot be told apart from the Home's write, so it is restored. Local opt-outs (`--local`) are your own choice on that Child and stay after disconnect.
-
-File integrations get the same round trip. At the first apply after linking, the Child records which file integrations it already had set up on its own (Aside profiles are not included). `ocx disconnect` keeps those that are still set up, sets up again the ones the Home turned off, and removes only the integrations the link added. A local opt-out, or a block you wrote yourself while linked, is kept. A restored integration first lists the models the Child last received from the Home; the next standalone start refreshes it from the local catalog. A Child that applied its Home's route intent before it ran a version that keeps this record has none, so `ocx disconnect` cannot tell which file integrations it had on its own: it removes the ones the link owns and does not set up again any the Home turned off. Set those up again after disconnecting.
-
-Disconnect removes owned keys and blocks while preserving later model, hook, project and Computer Use edits. Retry `ocx disconnect` to resume an interrupted disconnect, retaining `--keep-catalog` if the first attempt used it. For changed owned keys or evidenced legacy journals, `ocx disconnect --recover-config --yes` first saves a private current snapshot and then performs key-level recovery. It still refuses unknown ownership or an edited, unverified profile. Preserve the reported backup directory. Run `ocx service restart` after disconnect so a service-owned Child is replaced by standalone OpenCodex before linking again.
-
## Troubleshooting
When a step fails, the dashboard shows the reason and, when SSH reported one, a short sanitized hint from its last non-empty error line under the message. Remote-shell errors can appear there even when the remote shell emits non-UTF-8 text; OpenCodex removes terminal controls, link keys and URL queries and limits the hint's length.
@@ -251,8 +212,6 @@ To prepare a build, run `cargo build --release --locked --manifest-path native/o
Unlike a full `ocx` Child, a clean detached `ocx-child` host needs no pre-running background daemon before `ocx link add`: Home checks target-port availability and `~/.codex/config.toml` ownership before committing (`409 child_port_busy` if port `10100` is held by another process, or `409 child_external_provider_conflict` if another model provider or custom base URL already owns `config.toml`, leaving Child files untouched and rolling back any newly planted binary), pipes the link credential over SSH stdin, starts `ocx-child serve`, and confirms an authenticated `/v1/catalog` relay. Connected rows in `#remote` display a **Lightweight** / **Full install** badge and a **Home settings synced** / **Sync needed** parity badge. Running `ocx link revoke --link-id ` keeps the cached binary for fast re-attach, whereas `ocx link revoke --link-id --purge` (or `ocx-child detach --purge` on the Child) also deletes the planted binary from the managed cache directory.
-An `ocx-child` Child refreshes the Home catalog through `ocx sync` and never reads route intent. `ocx route to status` shows it as `route intent unsupported (ephemeral-child-no-route-intent)`. A route write naming it with `--device`, and `ocx route to sync --device` for it, are refused with `409 route_intent_unsupported` before anything changes; `ocx route to sync --device all` reports it `unsupported` without contacting it. Enrolling one saves no route intent, and `ocx sync` sends it the plain catalog sync even with `--overwrite-conflict`.
-
Keep `OPENCODEX_HOME` and `CODEX_HOME` pointed at the same directories throughout the connection. If detach reports a restore or shutdown error, keep the Child's recovery files and resolve the error before retrying.
### Windows computers
diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md
index fd544bb661c..db7efc81b07 100644
--- a/docs-site/src/content/docs/ko/guides/integrations.md
+++ b/docs-site/src/content/docs/ko/guides/integrations.md
@@ -76,8 +76,6 @@ OpenClaw에는 역할이 서로 다른 여러 설정이 있습니다. `OPENCLAW_
opencodex는 자체 환경에서 이 값을 읽습니다. 게이트웨이가 프로필이나 옮겨진 홈에서 실행된다면 opencodex도 같은 변수를 설정해 시작하세요. 그렇지 않으면 다른 설치를 올바르게 따라가게 됩니다.
-CLI의 `ocx integration client enable|disable --client `는 대시보드 토글처럼 이 컴퓨터만 바꿉니다. route 의도를 쓰는 Remote Link Home에서는 이 변경을 Home 자신의 결정으로 기록하므로 `ocx sync`가 유지하고, 연결된 Child는 기본값을 그대로 따릅니다. Child가 상속하는 값을 바꾸려면 `ocx route to on|off`를 사용하세요. Remote Link Child에서는 일반 토글을 거부하고 그 기기용 Home 명령을 출력합니다. 해당 Child만 제외하려면 그곳에서 `ocx route to off --local`을 사용하세요. [Remote Link](/ko/guides/remote-link/)를 참고하세요.
-
## 스위치가 아닌 다른 다섯 서피스
**API Keys**는 클라이언트가 아니라 opencodex 자체 자격 증명을 관리합니다. **Codex CLI**는 프록시 서비스가 직접 연결합니다. opencodex를 시작하면 적용되고 멈추면 네이티브 라우팅이 복원되므로 파일별 스위치가 없습니다. **Claude**는 자체 활성화 플래그와 Desktop의 Save/Apply 흐름을 유지하고, **Grok Build**는 모델을 선택한 뒤 적용하는 경계를 유지합니다. 모두 이 기능보다 먼저 있던 동작이며 바뀌지 않았습니다. **Cursor**는 아무 파일도 쓰지 않습니다. 탭에서 탐지 결과, 게이트웨이 값, 마지막 요청을 보여주고 나머지는 Cursor Private Inference 안에서 처리합니다.
diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md
index 9afcb8e5521..21673c7cb45 100644
--- a/docs-site/src/content/docs/ko/guides/remote-link.md
+++ b/docs-site/src/content/docs/ko/guides/remote-link.md
@@ -71,45 +71,6 @@ ocx disconnect # 경량 모듈 Child인 경우 ~/.cache/opencodex/bin/ocx-chil
자식 연결을 끊으려면 자식에서 `ocx disconnect`를 실행합니다. 이 명령은 클라이언트 터널을 끊고 SSH를 통해 홈에서 링크를 폐기합니다. 홈에서 폐기하지 못하면 다음 문구를 출력합니다: `Home revoke failed; run ocx link revoke --link-id on the home.`
-## 기기별 Route to
-
-Home의 Route to 의도는 Child에 기본으로 동기화됩니다. 각 기기는 자신의 설치된 클라이언트, 경로, 수신 포트, 제공 모델 카탈로그로 설정을 생성합니다. 우선순위는 기기 override > Home 기본값 > 내장 기본값(Codex·Claude Code만 켜짐)입니다.
-
-등록 시 클라이언트를 명시적으로 선택하지 않으면 Child는 Home 기본값과 이후 변경을 상속합니다. 클라이언트별 명령은 해당 클라이언트의 실패를 반환하고 다른 클라이언트의 미해결 충돌은 경고하며, 전체 sync는 모든 미해결 실패를 보고합니다. Claude Desktop first-party 모드는 이미 OpenCodex를 거치므로 켜기 의도는 아무것도 쓰지 않고 `current`로 표시합니다. 끄기 의도는 실행할 모드 전환 명령(Home에서는 `ocx integrations native claude-desktop off`, Child에서는 `ocx claude desktop apply --gateway` 후 `ocx route to sync`)을 알려 주는 충돌로 표시합니다.
-
-Home 자신에서 Codex, Claude Code, Grok, Claude Desktop은 Home의 켜기/끄기 스위치를 따릅니다. `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, Messages API 끄기, 에이전트 설정, 대시보드 토글이 바꾸는 바로 그 스위치입니다. 명시적인 `false`만 꺼짐이고 설정하지 않은 스위치는 켜짐입니다. Home 기본값과 기기 override는 Home에서 이 네 클라이언트를 결정하지 않습니다. 어떤 route 쓰기도 결정하고 있지 않은 Grok·Claude Desktop 스위치가 설정되지 않았거나 켜져 있으면 시작 시 동작과 같은 범위만 적용합니다. Grok은 시작 시 동기화 규칙을 따르고, Claude Desktop은 OpenCodex가 이미 소유한 gateway 프로필만 갱신하며 새로 만들거나 Desktop 모드를 바꾸지 않습니다. OpenCodex가 쓴 뒤 바뀐 소유 프로필은 갱신하지 않고 충돌로 보고합니다. OpenCodex가 소유한 적 없는 Desktop 라이브러리는 건드리지 않고 current로 보고하므로 전체 sync가 이 때문에 실패하지 않습니다. Desktop 프로필을 만들려면 `ocx route to claude-desktop on --device home`(또는 `ocx route to claude-desktop on`)을 실행하세요. 이것만으로 Child의 Grok·Claude Desktop이 켜지지는 않습니다.
-
-Child는 Home의 스위치를 읽지 않습니다. Child는 기기 override, 저장된 Home 기본값, 내장 기본값 순으로 따릅니다. Home은 이 네 클라이언트의 기본값을 한 번만 저장합니다. Codex와 Claude Code는 저장 시점의 Home 스위치 값(명시적으로 끄지 않았다면 켜짐)으로, Grok과 Claude Desktop은 꺼짐으로 저장합니다. Home 기본값 쓰기(`--device` 없는 `ocx route to on|off`)와 Child 등록은 항상 저장합니다. Home에 링크가 있으면 Home 전용 작업이 스위치를 바꾸기 전에도 저장합니다. 링크 리스너를 시작할 때, 그리고 `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, Messages API 끄기, Claude 토글, native 토글, `--device home` route 쓰기 안에서입니다. Home 전용 통합 변경(대시보드 토글, `ocx integration client enable|disable`, 통합 기록에서의 되돌리기, Aside 프로필 변경) 전에는 링크가 있는 Home이 Child가 상속하는 모든 기본값을 파일 통합까지 포함해 저장하며, 링크 리스너도 시작할 때 같은 일을 합니다. 그래서 이 네 기본값만 저장하던 버전에서 업그레이드한 Home도 무엇이 바뀌기 전에 나머지를 고정합니다. 경량 `ocx-child` 링크는 여기서 링크로 세지 않습니다. route 의도를 읽지 않으므로 경량 링크만 있는 Home에는 보호할 Child가 없습니다. 링크가 없는 Home에서는 이 Home 전용 명령이 기본값을 저장하지 않으므로, 나중에 등록한 Child는 등록 시점의 스위치 값을 상속합니다. 단, Home 기본값 쓰기로 기본값이 이미 저장되어 있으면 그 값을 상속합니다. 이전 기본값 쓰기로 이미 route 의도를 쓰고 있는 Home이라면, Home 전용 통합 변경은 `ocx sync`가 유지하도록 Home 자신의 행으로 기록되며, 이 행은 Child가 상속하지 않습니다. 링크 리스너가 시작할 때 저장에 실패하면(예: 다른 프로세스가 설정 잠금을 잡고 있을 때) 백그라운드에서 계속 다시 시도합니다. `ocx route to status`나 Child가 route 의도를 읽는 것은 아무것도 저장하지 않습니다. 그 뒤 Child가 상속하는 값은 Home 기본값 쓰기로만 바뀝니다. 위의 Home 전용 명령은 Home만 바꿉니다. 저장된 route 의도 파일(OpenCodex 설정 디렉터리의 `route-intent.json`)을 읽을 수 없으면, 링크가 있는 Home의 이 명령들과 모든 `ocx route to` 변경은 아무것도 바꾸지 않고 거부하며, 고치거나 지울 파일을 알려 줍니다. 링크가 없는 Home에서는 native 토글, `ocx restore`, Home 전용 통합 변경이 그대로 동작합니다. 이때 통합 변경은 아무것도 기록하지 않으며, `ocx sync`도 이 파일을 읽지 못하므로 그 변경을 되돌릴 수 없습니다.
-
-`ocx route to on|off --device home`이 Codex, Claude Code, Grok, Claude Desktop을 결정하면 먼저 그 클라이언트의 스위치를 저장한 뒤 적용합니다. 적용이 충돌이나 실패로 끝나면(예: 외부 Codex provider, 불완전한 Grok 블록, 바뀐 Claude Desktop 프로필) 명령은 실패하고 스위치가 저장되었는지 알려 줍니다. "The Home's grok switch was saved, but applying it ended in conflict"는 스위치가 바뀌었다는 뜻이고, "The Home did not apply"는 아무것도 바뀌지 않았다는 뜻입니다. first-party 모드의 Claude Desktop은 저장하기 전에 거부합니다.
-
-이 Home이 route 의도를 쓰기 시작하면(저장된 기본값이나 Home override가 있으면) `ocx sync`는 Home의 모든 클라이언트에 route 의도를 적용합니다. 그래서 Home 전용 통합 변경은 그 클라이언트에 대한 이 Home의 결정으로 기록되고 `ocx sync`가 유지합니다. 대시보드 토글, `ocx integration client enable|disable`(같은 Home 전용 결정이며, Child가 상속하는 값은 `ocx route to on|off`로 바꿉니다), 통합 기록에서의 되돌리기, Aside 프로필 변경이 여기에 해당합니다. Aside는 변경 뒤 Home이 라우팅하는 모든 프로필의 안정적인 Aside userId로 기록합니다. 링크된 Child는 기본값을 그대로 따릅니다. Aside의 `accounts.json`에 안정적인 userId가 없는 프로필이 있으면 이런 Home의 Aside 프로필 변경은 아무것도 바꾸기 전에 거부합니다(`route_intent_aside_unresolved`). 명세를 고치거나 `ocx route to aside on|off --device home`으로 Home의 Aside를 직접 결정하세요.
-
-```sh
-ocx route to status --device all --json
-ocx route to aside on --accounts all
-ocx route to aside on --device my-child --accounts stable-aside-user-id
-ocx route to pi off --device my-child
-ocx route to pi inherit --device my-child
-ocx route to sync --device my-child
-```
-
-`--device` 없는 Home 쓰기는 기본값을 바꾸고 Home에는 지정한 클라이언트만 같은 적용 경로로 반영합니다. Codex, Claude Code, Grok, Claude Desktop은 Home의 스위치를 써서 반영하므로 `ocx route to codex off`는 Home의 Codex를 끄고 Child가 상속하는 기본값도 바꿉니다. `--device home`(Home에서는 `this`)은 Home만 결정합니다. 이 네 클라이언트는 Home의 스위치만 쓰고 Child 기본값은 그대로 두며, `inherit`는 Home의 스위치를 현재 기본값(저장된 기본값이 없으면 내장 기본값)으로 맞춥니다. 다른 클라이언트는 Home override를 기록합니다. 다른 클라이언트에 대한 route 쓰기, `ocx route to sync`, Home에서 실행한 일반 `ocx sync`는 Home의 native 스위치를 바꾸지 않고 각 스위치가 가리키는 상태만 갱신하므로 `ocx restore`, `ocx restore back`, Messages API 끄기, 대시보드 토글을 되돌리지 않습니다. Child는 다음 동기화 tick에 반영합니다. Home이 추가한 Child는 명시적 sync로 즉시 적용합니다. `--overwrite-conflict`(`ocx route to`나 `ocx sync`)를 쓰면 Home은 OpenCodex 2.100.0 이상인 Child에서만 그 sync를 실행합니다. 이전 버전은 이 플래그를 무시하고 일반 sync를 성공으로 보고하기 때문이며, 그런 Child는 `child-ocx-lacks-overwrite`로 건너뜁니다. Child가 먼저 연결한 링크는 인증된 읽기 watch를 유지하며, Home sync가 이를 즉시 깨웁니다. 적용 보고가 도착하기 전에는 `pending`으로 표시하고, 활성 watch가 없으면 다음 tick을 기다린다고 안내합니다. 이 링크의 충돌 덮어쓰기는 Child에서 사람이 `ocx route to sync --overwrite-conflict`로 허용해야 합니다. Child의 로컬 sync는 즉시 적용합니다.
-
-Aside 계정은 로컬 `u/N` 번호가 아닌 **userId**로 선택합니다. `all`은 해당 기기의 모든 계정 프로필, `none`은 선택 없음입니다. Child의 일반 쓰기는 실행할 정확한 Home 명령을 안내하며 거부합니다. Child 사용자는 `ocx route to aside off --local`로 자신의 기기만 제외하고 `inherit --local`로 복귀할 수 있습니다. 로컬 `on`은 허용하지 않습니다. 연결된 Child에서 실행한 native 명령보다도 Home 의도가 우선합니다. 그곳의 `ocx restore`는 약 1분 뒤 다음 동기화가 Home 의도를 다시 적용할 때까지만 Codex 라우팅을 끕니다. 해당 Child에서 Codex를 계속 끄려면 `ocx route to codex off --local`을 사용하세요. Child는 1분마다 조정하며, 적용할 때마다 무엇을 바꾸기 전에 이전 적용 확인을 철회하므로 적용 중에 보낸 상태 보고는 다음 보고 전까지 잠시 미적용으로 보일 수 있습니다. 적용 중단이나 로컬 제외가 발생하면 Home의 이전 적용 확인을 철회합니다. 의도나 카탈로그 조회가 지연된 명시적 로컬 sync는 캐시된 off 상태가 정리되어 있어도 0이 아닌 종료 코드를 반환합니다. 토글에서 생략한 계정·모델 이름 정책은 유지됩니다. 소유 기록이 있는데 Aside 계정 명세에서 사라진 프로필은 소유권을 해결하기 전까지 완료로 표시하지 않습니다.
-
-상태 JSON은 `ocx.route-to/1`이며 의도·출처·적용 결과를 구분합니다. 출처는 `builtin`, `default`, `device`, `local`(Child 제외)이며, Home의 Codex·Claude Code·Grok·Claude Desktop 행은 Home 자신의 스위치를 뜻하는 `native`로 표시합니다. `not_installed`, `conflict`, `failed`는 성공으로 처리하지 않습니다. 의도가 바뀌면 이전 충돌이나 실패를 포함한 이전 결과는 모두 `stale`이 됩니다. 충돌 덮어쓰기는 해당 실행의 명시적 `--overwrite-conflict`가 필요합니다. 쓰기가 route 정책이나 Home 스위치를 저장한 뒤 적용이 예상치 못하게 실패하면 오류가 그 사실을 알리고 `desiredCommitted: true`를 담습니다. 경량 `ocx-child` Child는 `ocx sync`로 Home 카탈로그만 갱신하며 route 의도를 읽지 않습니다. `ocx route to status`는 이 Child를 `route intent unsupported (ephemeral-child-no-route-intent)`로 표시하고, 이 Child를 `--device`로 지정한 route 쓰기와 `ocx route to sync`는 아무것도 바꾸기 전에 `409 route_intent_unsupported`로 거부하며, `ocx route to sync --device all`은 접속하지 않고 `unsupported`로 보고합니다. 등록할 때 route 의도를 저장하지 않으며, `ocx sync`는 `--overwrite-conflict`가 있어도 일반 카탈로그 sync를 보냅니다. 자세한 동작은 [영문 가이드](/guides/remote-link/#route-clients-per-device)를 참고하세요.
-
-기존에 소유권 기록이 있는 연동은 Home 기본값으로 이전합니다. 명시적인 `connect --clients` 선택은 초기 기기 override로 유지하며, Home의 해당 기기 `on|off|inherit`가 그 선택을 대체합니다. Child 보고는 Home 정책을 변경하지 않습니다.
-
-Home이 결정한 클라이언트를 Home에 적용하지 못하면 그 쓰기는 실패합니다. first-party 모드인 Home에서 `ocx route to claude-desktop off`를 실행하면 `--device home` 여부와 관계없이 409로 응답하고, 해당 클라이언트의 충돌과 모드 전환 명령을 알려 주며, CLI는 0이 아닌 코드로 끝납니다. Home의 스위치를 쓰지 못한 경우도 같습니다. 기본값 쓰기라면 Child 기본값은 그대로 저장됩니다.
-
-연결된 Child는 Home의 실효 의도를 자신의 Codex, Claude Code, Claude Desktop, Grok 켜기/끄기 스위치에 기록합니다. Claude Code를 끄면 Messages API 설정도 고정될 수 있습니다. Child는 처음 바꾸기 전에 각 스위치의 독립형 값을, 설정되지 않았던 상태까지 포함해 기록합니다. `ocx disconnect`와 실패한 등록은 그 값을 되돌립니다. 실패한 등록이 값을 되돌리지 못하면 서비스 토큰을 유지하고 복원하지 못한 기록 파일을 알려 줍니다. 보고된 문제를 해결한 뒤 `ocx disconnect`를 실행하면 연결되지 않은 상태에서도 스위치를 복원하고 그 토큰을 제거합니다. 연결 중 사용자가 직접 바꾼 스위치는 그 값을 유지합니다. 다만 Home이 마지막으로 적용한 값과 정확히 같은 값으로 직접 설정한 스위치는 Home의 기록과 구별할 수 없어 복원됩니다. 로컬 제외(`--local`)는 해당 Child 사용자의 선택이므로 disconnect 후에도 유지됩니다.
-
-파일 연동도 같은 방식으로 되돌립니다. 링크 후 첫 적용 때 Child는 스스로 설정해 둔 파일 연동을 기록합니다(Aside 프로필은 제외). `ocx disconnect`는 아직 설정된 연동은 유지하고, Home이 끈 연동은 다시 설정하며, 링크가 추가한 연동만 제거합니다. 로컬 제외나 연결 중 직접 작성한 블록은 유지합니다. 복원된 연동은 처음에는 Child가 Home에서 마지막으로 받은 모델 목록을 쓰며, 다음 독립형 시작 때 로컬 카탈로그로 갱신됩니다. 이 기록을 남기는 버전을 실행하기 전에 이미 Home의 route 의도를 적용한 Child에는 기록이 없으므로, `ocx disconnect`는 링크가 소유한 연동만 제거하고 Home이 끈 연동을 다시 설정하지 않습니다. disconnect 후 직접 다시 설정하세요.
-
-disconnect는 소유한 키만 복구하므로 이후 편집한 모델·훅·신뢰 프로젝트·Computer Use 설정이 남습니다. 중단된 작업은 `ocx disconnect`로 재개하며 최초 사용한 `--keep-catalog`를 유지하세요. 소유 키 충돌이나 소유권 근거가 남은 이전 저널은 `ocx disconnect --recover-config --yes`로 현재 상태를 비공개 백업한 뒤 키 단위로 복구합니다. 소유권을 확인할 수 없거나 프로필이 편집되어 검증할 수 없으면 계속 거부합니다. 백업 경로를 보존하고, disconnect 후 `ocx service restart`로 독립형 런타임을 시작한 다음 다시 연결하세요.
-
## 보안
Child는 링크를 통해 Home 컴퓨터의 프로바이더와 프로바이더 인증 정보를 사용합니다. Home은 Child마다 별도의 링크 키를 만들며, 링크를 제거하면 그 키를 폐기합니다. 확인 전에 호스트 지문을 비교하여 잘못된 컴퓨터나 변경된 호스트 키를 실수로 허용하지 않도록 하세요. Tailscale identity로 발급된 대시보드 세션은 머신 링크를 관리할 수 없습니다. Child에서 키는 OpenCodex 안에만 머뭅니다. Codex나 Claude Code가 Child에서 보내는 인증 정보는 Home으로 전달되지 않으며, Child에서 `127.0.0.1:`에 접근하는 프로그램은 키 없이 Home을 사용합니다. 이는 독립형 설치가 로컬 프로그램에 주는 것과 같은 신뢰 수준입니다. 다른 사이트의 웹 페이지는 거부됩니다.
@@ -128,8 +89,6 @@ ocx link reconnect --link-id [--json]
비대화형 실행(TTY 없음)에서는 `--expect-fingerprint`가 필수입니다. 대화형 터미널에서는 옵션을 생략할 수 있지만, 제시된 지문을 명시적으로 확인해야 합니다.
-대시보드와 `ocx integrations native on|off`의 native 토글은 Home의 스위치를 바꾸고 해당 클라이언트의 전환(Claude Desktop first-party 전환 포함)을 실행하여 Child 기본값을 유지하며, Home에 대한 route 의도는 기록하지 않습니다. 이전 버전이 저장한 이 네 클라이언트의 Home override는 무시하고 다음 route 변경 때 제거합니다. Child에서 CLI는 거부하고 해당 기기의 Home 명령을 출력합니다. 링크를 폐기하면 그 기기의 override도 정리합니다. Claude Desktop의 first-party 모드는 보존합니다. 켜기 의도는 쓰기 없이 `current`이며, 끄기 의도는 모드 전환을 마칠 때까지 conflict로 표시합니다.
-
## 관련 가이드
- [Remote Hub 배포](/ko/guides/remote-hub/)
diff --git a/native/ocx-child/src/parity.rs b/native/ocx-child/src/parity.rs
index ff2ea5d3217..3d5be5ad6a1 100644
--- a/native/ocx-child/src/parity.rs
+++ b/native/ocx-child/src/parity.rs
@@ -69,11 +69,7 @@ fn json_safe_nonneg_int(val: &serde_json::Value) -> Option {
None
}
-/// Strict exact-key parser for the five-key base report `ocx-child` sends, capped at
-/// `LINK_CHILD_PARITY_MAX_BYTES` (1 KiB). It is narrower than the Home's TypeScript parser
-/// (`parseLinkChildParityReport` in `src/link/child-parity.ts`), which also accepts the extended
-/// report carrying `appliedRouteIntentDigest` and `routeClientOutcomes` and caps the body at 8 KiB.
-/// `ocx-child` never reads route intent, so it never sends those fields.
+/// Strict exact-key parser matching `parseLinkChildParityReport` in `src/link/child-parity.ts`.
pub fn parse_link_child_parity_report(raw_json: &str) -> Option {
if raw_json.len() > LINK_CHILD_PARITY_MAX_BYTES {
return None;
diff --git a/scripts/test-layout/layout-route-intent.json b/scripts/test-layout/layout-route-intent.json
deleted file mode 100644
index 52cc2101adb..00000000000
--- a/scripts/test-layout/layout-route-intent.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "cli-link-route-intent.test.ts": "cli",
- "cli-route-to-aliases.test.ts": "cli",
- "cli-route-to-confirmation.test.ts": "cli",
- "cli-route-to.test.ts": "cli",
- "client-disconnect-config-recovery.test.ts": "clients",
- "client-disconnect-edited-codex.test.ts": "clients",
- "client-disconnect-field-snapshot.test.ts": "clients",
- "codex-disconnect-fields.test.ts": "codex-integration",
- "codex-disconnect-owned-recovery.test.ts": "codex-integration",
- "codex-journal-provider-reserialization.test.ts": "codex-integration",
- "home-route-intent-upgrade.test.ts": "clients",
- "link-home-route-intent.test.ts": "clients",
- "link-route-intent.test.ts": "server",
- "management-route-intent-native-errors.test.ts": "server",
- "management-route-intent-native-review.test.ts": "server",
- "route-intent-aside.test.ts": "clients",
- "route-intent-claude-surface.test.ts": "clients",
- "route-intent-codex-connect-disabled.test.ts": "clients",
- "route-intent-codex-transitions.test.ts": "clients",
- "route-intent-connect-recovery.test.ts": "clients",
- "route-intent-connected-review.test.ts": "clients",
- "route-intent-connected.test.ts": "clients",
- "route-intent-desktop.test.ts": "clients",
- "route-intent-file-originals.test.ts": "clients",
- "route-intent-home-consent.test.ts": "clients",
- "route-intent-local-receipt.test.ts": "clients",
- "route-intent-materializer.test.ts": "clients",
- "route-intent-native-originals.test.ts": "clients",
- "route-intent-native-review.test.ts": "clients",
- "route-intent-offline-mode-switch.test.ts": "clients",
- "route-intent-overwrite.test.ts": "clients",
- "route-intent-parity-fallback.test.ts": "clients",
- "route-intent-parity-withdrawal.test.ts": "clients",
- "route-intent-parity.test.ts": "clients",
- "route-intent-reporter.test.ts": "clients",
- "route-intent-runtime-stop.test.ts": "clients",
- "route-intent-scheduler.test.ts": "clients",
- "route-intent-state.test.ts": "clients",
- "route-intent-watch-boundary.test.ts": "server",
- "route-intent-watch-client.test.ts": "clients",
- "route-intent-watch-waiters.test.ts": "server",
- "route-intent.test.ts": "clients",
- "route-to-child-defaults.test.ts": "routing",
- "route-to-home-live-flags.test.ts": "routing",
- "link-pinned-sync-overwrite.test.ts": "clients",
- "route-to-ephemeral.test.ts": "server",
- "route-to-home-decisions.test.ts": "routing",
- "cli-sync-fanout-ephemeral.test.ts": "cli",
- "route-to-home-native-truth.test.ts": "routing",
- "route-to-home-off-review.test.ts": "routing",
- "route-to-live-settings.test.ts": "server",
- "route-to-policy-review.test.ts": "server",
- "route-to-retirement-review.test.ts": "server",
- "route-to-routes.test.ts": "server",
- "route-to-sync-child.test.ts": "routing",
- "service-disconnected-client-restart.test.ts": "service",
- "service-restart-registration-guard.test.ts": "service",
- "service-scheduler-restart-cleanup.test.ts": "service"
-}
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index d9a7b164e61..aed2989c321 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -1,7 +1,7 @@
{
"version": 1,
"root": "tests",
- "explicitShards": ["layout-route-intent.json", "layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"],
+ "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"],
"explicit": { "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows",
"stop-restore-history-ownership.test.ts": "service",
"cli-stop-refusal-transport.test.ts": "cli",
diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md
index f01318a08f4..d49db6cfa0b 100644
--- a/skills/ocx/references/01_management_surface.md
+++ b/skills/ocx/references/01_management_surface.md
@@ -28,7 +28,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin
| [lifecycle](01_surface_lifecycle.md) | 14 |
| [providers-models](01_surface_providers-models.md) | 50 |
| [accounts](01_surface_accounts.md) | 43 |
-| [agents-routing](01_surface_agents-routing.md) | 71 |
+| [agents-routing](01_surface_agents-routing.md) | 68 |
| [integrations](01_surface_integrations.md) | 41 |
| [observe-system](01_surface_observe-system.md) | 99 |
| [access-remote](01_surface_access-remote.md) | 34 |
@@ -1527,20 +1527,8 @@ Original invocation order. These headings preserve links to the previous single-
[State-changing task](01_surface_lab.md#ocx-lab-run)
-### `ocx route to`
-
-[State-changing task](01_surface_agents-routing.md#ocx-route-to)
-
-### `ocx route to status`
-
-[Read-oriented task](01_surface_agents-routing.md#ocx-route-to-status)
-
-### `ocx route to sync`
-
-[State-changing task](01_surface_agents-routing.md#ocx-route-to-sync)
-
## Counts
-- declared capabilities: 373
-- of those, state-changing: 230
+- declared capabilities: 370
+- of those, state-changing: 228
- head-resolved invocations: 2
diff --git a/skills/ocx/references/01_surface_access-remote.md b/skills/ocx/references/01_surface_access-remote.md
index bdcc876c627..3a9df271275 100644
--- a/skills/ocx/references/01_surface_access-remote.md
+++ b/skills/ocx/references/01_surface_access-remote.md
@@ -81,7 +81,6 @@ State-changing: yes. JSON mode: `payload`.
| Flag | Value | Meaning |
|---|---|---|
-| `--clients` | string | Initial device selection; defaults to Codex and Claude Code. Other Home client defaults remain inherited. |
| `--alias` | string | SSH host alias for the linked machine. |
| `--tunnel-port` | number | Remote loopback port for the reverse tunnel. |
| `--json` | boolean | Emit the issue result as JSON. |
@@ -329,7 +328,7 @@ State-changing: yes. JSON mode: `payload`.
### `ocx disconnect`
-Usage: `ocx disconnect [--keep-catalog] [--recover-config --yes] [--json] | ocx disconnect --orphaned-token --yes [--json]`
+Usage: `ocx disconnect [--keep-catalog] [--json] | ocx disconnect --orphaned-token --yes [--json]`
Disconnect a client or preserve orphaned token files for recovery.
@@ -340,9 +339,8 @@ Drives no management route.
| Flag | Value | Meaning |
|---|---|---|
| `--keep-catalog` | boolean | Keep the connected client's catalog during ordinary disconnect. |
-| `--recover-config` | boolean | With --yes, snapshot current Codex artifacts and recover only evidenced owned keys; unknown ownership still refuses. |
| `--orphaned-token` | boolean | Preserve unowned service-token files in a private backup when disconnected. |
-| `--yes` | boolean | Explicitly confirm --recover-config or --orphaned-token recovery. |
+| `--yes` | boolean | Explicitly confirm orphan-token recovery; requires --orphaned-token. |
| `--json` | boolean | Emit the local result and backup location as JSON. |
- Orphan recovery requires a disconnected durable state, no pending connect or disconnect, no Hub or admission-secret use, no installed or unverified service, and verified absence of a client runtime. It preserves rather than deletes token files and does not establish their ownership.
diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md
index 10187ebd5ec..b30b7d6f50c 100644
--- a/skills/ocx/references/01_surface_agents-routing.md
+++ b/skills/ocx/references/01_surface_agents-routing.md
@@ -8,7 +8,7 @@
Use these declarations to choose a task, then check its flags and authority before execution.
Non-mutating probes may still contact providers, consume quota or refresh caches.
-Declared capabilities: 71.
+Declared capabilities: 68.
### `ocx agent subagents force`
@@ -1339,69 +1339,3 @@ State-changing: no. JSON mode: `payload`.
| `--json` | boolean | Emit the result as JSON. |
- Alias of observe memory.
-
-### `ocx route to`
-
-Usage: `ocx route to [--device ] [--accounts ] [--model-names ] [--overwrite-conflict] [--local] [--json]`
-
-Set Home-default or per-device route intent, or opt out locally on a Child.
-
-State-changing: yes. JSON mode: `payload`.
-
-| Method | Route |
-|---|---|
-| PUT | `/api/route-to` |
-
-| Flag | Value | Meaning |
-|---|---|---|
-| `--device` | string | Home: alias, id, all or this. Child: this or its own link id only. |
-| `--json` | boolean | Emit the ocx.route-to/1 status matrix. |
-| `--overwrite-conflict` | boolean | Explicitly permit replacing conflicting managed blocks. |
-| `--local` | boolean | Child only: opt out with off, or clear the opt-out with inherit. |
-| `--accounts` | string | Aside on only: all, none, or comma-separated stable userIds; never u/N slots. |
-| `--model-names` | string | Hermes/Command Code on only: alias or routed model names. |
-
-- Clients: codex, claude, claude-desktop, grok and the supported file integrations. Without --device a Home updates its default and materializes itself; all is only for status/sync.
-- inherit requires --device unless clearing a Child --local opt-out. Child writes print the exact Home command with the Child link id; --local supports off/inherit only. --overwrite-conflict applies only to on or sync.
-- On the Home, codex, claude, grok and claude-desktop follow the Home's own native switches: a Home default or --device home write for them writes that switch (Home inherit sets it to the default).
-
-### `ocx route to status`
-
-Usage: `ocx route to status [--device ] [--json]`
-
-Read per-device route intent, its source and actual client apply outcomes.
-
-State-changing: no. JSON mode: `payload`.
-
-| Method | Route |
-|---|---|
-| GET | `/api/route-to` |
-
-| Flag | Value | Meaning |
-|---|---|---|
-| `--device` | string | Home: alias, id, all or this. Child: this or its own link id only. |
-| `--json` | boolean | Emit the ocx.route-to/1 status matrix. |
-
-- On a Child this reads local receipts without a Home management relay. Intent and applied state are distinct; null applied means no recorded outcome.
-- Sources: builtin, default, device, local (Child opt-out) and native (a Home row governed by the Home's own switch). An outcome recorded for an earlier intent reads stale.
-
-### `ocx route to sync`
-
-Usage: `ocx route to sync [--device ] [--overwrite-conflict] [--json]`
-
-Reapply route intent on selected devices, or locally on a Child.
-
-State-changing: yes. JSON mode: `payload`.
-
-| Method | Route |
-|---|---|
-| POST | `/api/route-to/sync` |
-
-| Flag | Value | Meaning |
-|---|---|---|
-| `--device` | string | Home: alias, id, all or this. Child: this or its own link id only. |
-| `--overwrite-conflict` | boolean | Explicitly permit replacing conflicting managed blocks. |
-| `--json` | boolean | Emit the ocx.route-to/1 status matrix. |
-
-- No conflict retry or implicit overwrite. Child sync calls its local materializer and sends no Home management write.
-- Home sync never changes the Home's native switches for codex, claude, grok or claude-desktop; it refreshes what each switch says.
diff --git a/skills/ocx/references/01_surface_integrations.md b/skills/ocx/references/01_surface_integrations.md
index f7e55100a69..067e554644d 100644
--- a/skills/ocx/references/01_surface_integrations.md
+++ b/skills/ocx/references/01_surface_integrations.md
@@ -192,7 +192,7 @@ State-changing: yes. JSON mode: `payload`.
|---|---|---|
| `--json` | boolean | Emit the client rows or toggle result as JSON. |
-- Each on/off writes the Home's native switch, recording no route intent for the Home, and applies it through the client's runtime owner, including Claude Desktop's first-party transition; Children keep inheriting their saved default. Refused disables remain failures. On a Child it is refused with the exact Home command. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor.
+- Each toggle writes the selected client configuration through its runtime owner. Refused disables remain failures. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor.
### `ocx integration client`
@@ -228,7 +228,7 @@ State-changing: yes. JSON mode: `payload`.
| `--confirm-drift` | boolean | Explicitly allow replacing edits made after the snapshot. |
- status/show/list reads all clients, one client or Aside profiles. history/journal reads rollback records; expired snapshots stay visible.
-- enable/disable requires --client; an omitted Aside --profile toggles all profiles. Each toggle is this Home's own decision, like the dashboard: once the Home uses route intent it is recorded so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). --overwrite-conflict applies only to enable.
+- enable/disable requires --client; an omitted Aside --profile toggles all profiles. --overwrite-conflict applies only to enable.
- restore requires --op (alias --op-id); --client requires --profile and only Aside supports that profile selector. --confirm-drift is an explicit user waiver, never auto-retried. Use client preview or restore --preview to inspect a change, history remove --op ID --yes to retire a journal row, and sync --client aside to refresh managed profiles.
### `ocx grok status`
@@ -417,7 +417,7 @@ State-changing: yes. JSON mode: `payload`.
| `--reasoning-default` | string | Repeat MODEL=EFFORT to replace the full Droid defaults map; exact model IDs, duplicate keys refused. |
| `--clear-reasoning-defaults` | boolean | Droid apply/overwrite only: send an empty map, distinct from omitted inherited defaults. |
-- A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.
+- An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.
- Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.
- Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview.
- Droid map omission preserves inheritance; supplied entries replace the full map, clear sends {}. Only Droid apply/overwrite accepts these options.
@@ -443,7 +443,7 @@ State-changing: yes. JSON mode: `payload`.
| `--json` | boolean | Emit the result as JSON. |
| `--plan-fingerprint` | string | Optional versioned token from a matching preview; stale intent is refused without retry. |
-- A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.
+- An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.
- Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.
- Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview.
diff --git a/skills/ocx/references/01_surface_lifecycle.md b/skills/ocx/references/01_surface_lifecycle.md
index 9d4e6edbac6..ce7372e8b50 100644
--- a/skills/ocx/references/01_surface_lifecycle.md
+++ b/skills/ocx/references/01_surface_lifecycle.md
@@ -85,7 +85,7 @@ Drives no management route.
### `ocx sync`
-Usage: `ocx sync [--expect-link | --expect-hub-key ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]`
+Usage: `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]`
Synchronize client catalogs, including Aside profiles through the running server's mutation owner.
@@ -97,8 +97,6 @@ State-changing: yes. JSON mode: `payload`.
| Flag | Value | Meaning |
|---|---|---|
-| `--overwrite-conflict` | boolean | Allow conflicting route materialization only for this explicit sync. |
-| `--overwrite-client` | string | Restrict takeover to one client; requires --overwrite-conflict. |
| `--local-only` | boolean | Do not fan out the Home sync to linked Children. |
| `--json` | boolean | Emit the sync fan-out result as JSON. |
| `--restart-codex` | boolean | Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog, cache, or changed configuration write, on macOS, Linux and Windows. |
diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts
index 3b91d3b26c4..25d0f85e509 100644
--- a/src/claude/desktop-3p.ts
+++ b/src/claude/desktop-3p.ts
@@ -1,6 +1,6 @@
import { readClientConnectionState } from "../client/state";
import { readServiceApiTokenState, readTokenBackupState } from "../lib/service-secrets";
-import { assertClientLifecycleHeld, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
+import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesktopStore } from "./desktop-remote-store";
import { canonicalDirectory } from "./desktop-remote-store-io";
import {
@@ -571,7 +571,6 @@ export function removeDesktop3pStandardPivot(
options: Desktop3pConfigLibraryOptions & {
appliedFingerprint?: string | null; unlink?: (path: string) => void;
lifecycleLockDeps?: ClientLifecycleLockDeps;
- lifecycleHeld?: ClientLifecycleHeld;
/**
* The desired-state guard below exists for OFF flows racing a concurrent enable. A
* mode switch (gateway → first-party) removes the profile while the integration stays
@@ -582,7 +581,7 @@ export function removeDesktop3pStandardPivot(
): Desktop3pRemovalResult {
const libraryPath = resolveDesktop3pConfigLibraryPath(options);
try {
- const remove = (held: ClientLifecycleHeld): Desktop3pRemovalResult => withConfigMutationLockSync(() => {
+ return withClientLifecycleSync(held => withConfigMutationLockSync(() => {
const connection = readClientConnectionState();
if (connection.kind === "invalid" || connection.kind === "mismatched") {
return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: `desktop_client_state_${connection.kind}` };
@@ -622,9 +621,7 @@ export function removeDesktop3pStandardPivot(
return result.ok
? { ok: true, changed: result.changed, kind: result.changed ? "removed" : "noop", libraryPath }
: { ok: false, changed: result.changed, kind: result.reason === "cleanup_pending" ? "cleanup_incomplete" : "unsafe", reason: result.reason, libraryPath };
- });
- if (options.lifecycleHeld) { assertClientLifecycleHeld(options.lifecycleHeld); return remove(options.lifecycleHeld); }
- return withClientLifecycleSync(remove, options.lifecycleLockDeps);
+ }), options.lifecycleLockDeps);
} catch (error) {
return { ok: false, changed: false, kind: "write_failed", libraryPath, reason: desktopMutationFailureReason(error) };
}
@@ -727,10 +724,9 @@ export function writeDesktop3pConfig(
nativeContextCap?: NativeContextLimitsInput,
lifecycleLockDeps?: ClientLifecycleLockDeps,
refreshOnly?: { appliedFingerprint: string; admit: () => boolean },
- lifecycleHeld?: ClientLifecycleHeld,
): { written: boolean; path: string; reason?: string; fingerprint?: string } {
try {
- const write = () => withConfigMutationLockSync(() => {
+ return withClientLifecycleSync(() => withConfigMutationLockSync(() => {
const connection = readClientConnectionState();
if (connection.kind === "invalid" || connection.kind === "mismatched") {
return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: `desktop_client_state_${connection.kind}` };
@@ -789,9 +785,7 @@ export function writeDesktop3pConfig(
generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap,
desktop3pModelOptions(latest.config.claudeCode))
), requiredId && refreshOnly ? { requiredId, expectedFingerprint: refreshOnly.appliedFingerprint, admit: refreshOnly.admit } : undefined);
- });
- if (lifecycleHeld) { assertClientLifecycleHeld(lifecycleHeld); return write(); }
- return withClientLifecycleSync(write, lifecycleLockDeps);
+ }), lifecycleLockDeps);
} catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; }
}
diff --git a/src/claude/desktop-gateway-state.ts b/src/claude/desktop-gateway-state.ts
index d9b1d4c373b..322970c0560 100644
--- a/src/claude/desktop-gateway-state.ts
+++ b/src/claude/desktop-gateway-state.ts
@@ -25,12 +25,10 @@ export function persistCommittedDesktopGateway(
snapshot: OcxConfig,
profile: DesktopProfile | undefined,
fingerprint: string | undefined,
- admit?: (current: OcxConfig) => boolean,
): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" | "unavailable" } {
const appliedAt = new Date().toISOString();
try {
const outcome = mutatePersistedConfig(current => {
- if (admit && !admit(current)) return { changed: false, value: null };
if (JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(profile)) {
return { changed: false, value: null };
}
diff --git a/src/claude/desktop-remote-store-artifact.ts b/src/claude/desktop-remote-store-artifact.ts
index cc79200d22a..ecefac46cc6 100644
--- a/src/claude/desktop-remote-store-artifact.ts
+++ b/src/claude/desktop-remote-store-artifact.ts
@@ -5,7 +5,7 @@ import { readServiceApiTokenState, readTokenBackupState, serviceApiTokenFingerpr
import { profilePath, type Desktop3pMetadata } from "./desktop-3p-library";
import { StoreIO, metadata, storePaths, MAX_DESKTOP_METADATA_ENTRIES, type JsonFile } from "./desktop-remote-store-io";
import {
- DesktopStoreError, canonical, digest, origin, parseBaseline, parseDisconnect, parseOwner, parseState, projection, projectionHash, mergeProjection,
+ DesktopStoreError, digest, origin, parseBaseline, parseDisconnect, parseOwner, parseState, projection, projectionHash, mergeProjection,
sameOwner, type ArtifactPending, type Baseline, type DesktopRemoteOwner, type Projection, type StoreState,
} from "./desktop-remote-store-state";
@@ -125,7 +125,7 @@ export function establish(io: StoreIO, owner: DesktopRemoteOwner, known: readonl
return bundle;
}
-export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: ArtifactPending["kind"], tokenFingerprint: string, desiredSelection?: string | null, overwriteConflict = false): void {
+export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: ArtifactPending["kind"], tokenFingerprint: string, desiredSelection?: string | null): void {
const state = bundle.state!;
const targetPath = profilePath(bundle.paths.library, state.targetId);
let current = io.read(targetPath);
@@ -143,18 +143,7 @@ export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: Ar
if (pending.kind !== kind || pending.after !== after || pending.tokenFingerprint !== tokenFingerprint
|| (before !== pending.before && before !== pending.after)) throw new DesktopStoreError("conflict");
if (kind === "apply" && selection !== pending.beforeSelection && selection !== pending.afterSelection) throw new DesktopStoreError("conflict");
- } else if (before !== state.lastProjectionHash) {
- // Consent covers only edited fields of the selected, settled, already-owned row.
- // Owner, path, metadata, token and pending-transaction checks remain mandatory.
- if (!overwriteConflict || kind !== "apply" || state.phase !== "active" || !current
- || !existing || selection !== state.targetId) throw new DesktopStoreError("conflict");
- const snapshotPath = join(bundle.paths.root, "overwrites", `${current.hash}.json`);
- const snapshot = io.read(snapshotPath, 1024 * 1024, true);
- if (snapshot && canonical(snapshot.value) !== canonical(current.value)) throw new DesktopStoreError("conflict");
- if (!snapshot) io.write(snapshotPath, current.value, null);
- // artifact's ordinary pending record now journals this exact edited preimage.
- io.compare(targetPath, current);
- }
+ } else if (before !== state.lastProjectionHash) throw new DesktopStoreError("conflict");
const afterSelection = desiredSelection === undefined ? selection : desiredSelection;
const settledPhase = kind === "restore" ? "restored" : "active";
if (!pending && existing && before === after && afterSelection === selection
diff --git a/src/claude/desktop-remote-store.ts b/src/claude/desktop-remote-store.ts
index 1ae510dfbd5..3e18307c980 100644
--- a/src/claude/desktop-remote-store.ts
+++ b/src/claude/desktop-remote-store.ts
@@ -81,32 +81,24 @@ export function inspectRemoteDesktopStore(owner: DesktopRemoteOwner): {
export function applyRemoteDesktopStore(held: ClientLifecycleHeld, options: {
owner: DesktopRemoteOwner; expectedTokenFingerprint: string;
baseUrl: string; apiKey: string; mode: Desktop3pConfigMode; models: Desktop3pModelEntry[];
- /** Link-only local materialization: exact Child listener and a non-secret placeholder. */
- localListener?: boolean;
- /** One operation's consent to replace edited fields of this already-owned profile. */
- overwriteConflict?: boolean;
}): DesktopStoreResult {
return mutation(held, io => {
const { config, client } = currentConnection(options.owner);
noDisconnect(io);
if (client.pendingOperation) throw new DesktopStoreError("conflict");
const token = readServiceApiTokenState();
- const local = options.localListener === true;
- const validDestination = local
- ? client.transport === "link" && !!client.link && options.baseUrl === `http://127.0.0.1:${config.port}` && options.apiKey === "opencodex-loopback"
- : serviceApiTokenFingerprint(options.apiKey) === (token.kind === "present" ? token.fingerprint : "") && origin(options.baseUrl) === options.owner.serverUrl;
if (token.kind !== "present" || token.fingerprint !== options.expectedTokenFingerprint || token.fingerprint !== client.tokenFingerprint
- || !validDestination) throw new DesktopStoreError("conflict");
+ || serviceApiTokenFingerprint(options.apiKey) !== token.fingerprint || origin(options.baseUrl) !== options.owner.serverUrl) throw new DesktopStoreError("conflict");
if (config.clientIntegrations?.["claude-desktop"] === false) throw new DesktopStoreError("desired_disabled");
if (!options.models.length || !["static", "hybrid", "discovery"].includes(options.mode)) throw new DesktopStoreError("unsafe");
assertDesktop3pModelsValid(options.models);
const bundle = establish(io, options.owner, [token.fingerprint, ...serviceGenerations()], false)!;
const value = {
- inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: local ? options.baseUrl : options.owner.serverUrl,
+ inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: options.owner.serverUrl,
inferenceGatewayApiKey: options.apiKey, modelDiscoveryEnabled: options.mode !== "static",
...(options.mode === "discovery" ? {} : { inferenceModels: options.models }),
};
- artifact(io, bundle, value, "apply", token.fingerprint, bundle.state!.targetId, options.overwriteConflict);
+ artifact(io, bundle, value, "apply", token.fingerprint, bundle.state!.targetId);
cleanBackup(io, bundle, [token.fingerprint, ...serviceGenerations()]);
return success(io, bundle, "applied");
});
@@ -131,15 +123,6 @@ export function replaceRemoteDesktopCredential(held: ClientLifecycleHeld, option
const file = io.read(profilePath(bundle.paths.library, bundle.state!.targetId));
if (!file) throw new DesktopStoreError("unsafe");
const credential = profileCredential(file.value);
- const local = client.transport === "link" && !!client.link
- && file.value.inferenceGatewayBaseUrl === `http://127.0.0.1:${config.port}` && file.value.inferenceGatewayApiKey === "opencodex-loopback";
- if (local) {
- // artifact still verifies the recorded projection and selection. Only the service-key
- // generation changes; the local file never receives that secret during rotation.
- artifact(io, bundle, projection(file.value), "rotate", replacement);
- cleanBackup(io, bundle, [options.expectedTokenFingerprint, ...generations]);
- return success(io, bundle, "updated");
- }
if (!credential || credential.origin !== options.owner.serverUrl
|| ![options.expectedTokenFingerprint, replacement].includes(credential.fingerprint)) throw new DesktopStoreError("conflict");
artifact(io, bundle, { ...projection(file.value), inferenceGatewayApiKey: options.replacementKey }, "rotate", replacement);
diff --git a/src/cli/capabilities-access-remote.ts b/src/cli/capabilities-access-remote.ts
index d5773102f0e..add2702b22f 100644
--- a/src/cli/capabilities-access-remote.ts
+++ b/src/cli/capabilities-access-remote.ts
@@ -4,13 +4,12 @@ import type { Capability } from "./capability-types";
export const ACCESS_REMOTE_CAPABILITIES: readonly Capability[] = [
{
command: ["disconnect"], summary: "Disconnect a client or preserve orphaned token files for recovery.",
- usage: "ocx disconnect [--keep-catalog] [--recover-config --yes] [--json] | ocx disconnect --orphaned-token --yes [--json]",
+ usage: "ocx disconnect [--keep-catalog] [--json] | ocx disconnect --orphaned-token --yes [--json]",
routes: [],
flags: [
{ name: "--keep-catalog", value: "boolean", summary: "Keep the connected client's catalog during ordinary disconnect." },
- { name: "--recover-config", value: "boolean", summary: "With --yes, snapshot current Codex artifacts and recover only evidenced owned keys; unknown ownership still refuses." },
{ name: "--orphaned-token", value: "boolean", summary: "Preserve unowned service-token files in a private backup when disconnected." },
- { name: "--yes", value: "boolean", summary: "Explicitly confirm --recover-config or --orphaned-token recovery." },
+ { name: "--yes", value: "boolean", summary: "Explicitly confirm orphan-token recovery; requires --orphaned-token." },
{ name: "--json", value: "boolean", summary: "Emit the local result and backup location as JSON." },
],
mutates: true, json: "payload",
diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts
index 5f2b8a4f47b..b79e3ba6a65 100644
--- a/src/cli/capabilities-base.ts
+++ b/src/cli/capabilities-base.ts
@@ -51,7 +51,7 @@ export const CAPABILITIES: readonly Capability[] = [
command: ["link","issue"],
summary: "Issue one link credential and record its tunnel metadata.",
routes: [{"method":"POST","path":"/api/link/issue"}],
- flags: [{"name":"--clients","value":"string","summary":"Initial device selection; defaults to Codex and Claude Code. Other Home client defaults remain inherited."},{"name":"--alias","value":"string","required":true,"summary":"SSH host alias for the linked machine."},{"name":"--tunnel-port","value":"number","required":true,"summary":"Remote loopback port for the reverse tunnel."},{"name":"--json","value":"boolean","summary":"Emit the issue result as JSON."}],
+ flags: [{"name":"--alias","value":"string","required":true,"summary":"SSH host alias for the linked machine."},{"name":"--tunnel-port","value":"number","required":true,"summary":"Remote loopback port for the reverse tunnel."},{"name":"--json","value":"boolean","summary":"Emit the issue result as JSON."}],
mutates: true,
json: "payload",
details: ["Human-only handoff: issues a credential and prints its plaintext once. Never capture it in an agent transcript. Requires loopback admin authority; tunnel port must be 1024-65535."],
@@ -752,7 +752,7 @@ export const CAPABILITIES: readonly Capability[] = [
flags: [{"name":"--json","value":"boolean","summary":"Emit the client rows or toggle result as JSON."}],
mutates: true,
json: "payload",
- details: ["Each on/off writes the Home's native switch, recording no route intent for the Home, and applies it through the client's runtime owner, including Claude Desktop's first-party transition; Children keep inheriting their saved default. Refused disables remain failures. On a Child it is refused with the exact Home command. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor."],
+ details: ["Each toggle writes the selected client configuration through its runtime owner. Refused disables remain failures. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor."],
usage: "ocx integration native [list] [--json]; ocx integration native [--json]",
},
{
@@ -762,7 +762,7 @@ export const CAPABILITIES: readonly Capability[] = [
flags: [{"name":"--model-names","value":"string","summary":"Hermes/Command Code apply or overwrite only: alias or routed; omission preserves managed policy."},{"name":"--client","value":"string","summary":"File integration ID, including gentle-shell; aside selects profiles."},{"name":"--profile","value":"number","summary":"Aside nonnegative integer account ID; requires --client aside."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."},{"name":"--overwrite-conflict","value":"boolean","summary":"Explicitly permit replacing a conflicting block."},{"name":"--op","value":"string","summary":"Operation ID; --op-id is an alias."},{"name":"--confirm-drift","value":"boolean","summary":"Explicitly allow replacing edits made after the snapshot."}],
mutates: true,
json: "payload",
- details: ["status/show/list reads all clients, one client or Aside profiles. history/journal reads rollback records; expired snapshots stay visible.","enable/disable requires --client; an omitted Aside --profile toggles all profiles. Each toggle is this Home's own decision, like the dashboard: once the Home uses route intent it is recorded so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). --overwrite-conflict applies only to enable.","restore requires --op (alias --op-id); --client requires --profile and only Aside supports that profile selector. --confirm-drift is an explicit user waiver, never auto-retried. Use client preview or restore --preview to inspect a change, history remove --op ID --yes to retire a journal row, and sync --client aside to refresh managed profiles."],
+ details: ["status/show/list reads all clients, one client or Aside profiles. history/journal reads rollback records; expired snapshots stay visible.","enable/disable requires --client; an omitted Aside --profile toggles all profiles. --overwrite-conflict applies only to enable.","restore requires --op (alias --op-id); --client requires --profile and only Aside supports that profile selector. --confirm-drift is an explicit user waiver, never auto-retried. Use client preview or restore --preview to inspect a change, history remove --op ID --yes to retire a journal row, and sync --client aside to refresh managed profiles."],
usage: "ocx integration client status [--client ] [--profile ] [--json]; ocx integration client history [--client ] [--profile ] [--json]; ocx integration client enable --client [--profile ] [--overwrite-conflict] [--model-names ] [--json]; ocx integration client disable --client [--profile ] [--json]; ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json]",
},
{
@@ -770,8 +770,6 @@ export const CAPABILITIES: readonly Capability[] = [
summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.",
routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }],
flags: [
- { name: "--overwrite-conflict", value: "boolean", summary: "Allow conflicting route materialization only for this explicit sync." },
- { name: "--overwrite-client", value: "string", summary: "Restrict takeover to one client; requires --overwrite-conflict." },
{ name: "--local-only", value: "boolean", summary: "Do not fan out the Home sync to linked Children." },
{ name: "--json", value: "boolean", summary: "Emit the sync fan-out result as JSON." },
{ name: "--restart-codex", value: "boolean", summary: "Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog, cache, or changed configuration write, on macOS, Linux and Windows." },
@@ -789,7 +787,7 @@ export const CAPABILITIES: readonly Capability[] = [
"After a successful Home sync, a connected Child skipped during fan-out, a failed or timed-out Child, or an unreadable link store yields partial exit 6. Skipped connected Children include an actionable hint; update an outdated ocx through its owning installer, with the Desktop tray as an option for Desktop installs. Intentional skips, including client-initiated links and --local-only, keep their existing exit behavior.",
"--expect-link/--expect-hub-key are checked under the lock that selects the sync path. A mismatch writes nothing, never falls back to local providers, exits 5, and ends stderr with {\"phase\":\"child_link_mismatch\"|\"hub_client_mismatch\",\"reason\":...}; reasons are disconnected, invalid_state, disconnect_pending, pending_operation, hub_client, link_client, other_link and other_key. Usage errors exit 2.",
],
- usage: "ocx sync [--expect-link | --expect-hub-key ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]",
+ usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]",
},
{
command: ["agent","subagents","force"],
diff --git a/src/cli/capabilities-integrations.ts b/src/cli/capabilities-integrations.ts
index 5df20204675..338240652c8 100644
--- a/src/cli/capabilities-integrations.ts
+++ b/src/cli/capabilities-integrations.ts
@@ -89,7 +89,7 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [
flags: [{"name":"--model-names","value":"string","summary":"Hermes/Command Code apply or overwrite only: alias or routed; omission preserves managed policy."},{"name":"--client","value":"string","summary":"Explicit file integration ID.","required":true},{"name":"--profile","value":"number","summary":"Aside nonnegative integer account ID; requires --client aside."},{"name":"--overwrite-conflict","value":"boolean","summary":"Explicitly permit replacing a conflicting block."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."},{"name":"--plan-fingerprint","value":"string","summary":"Optional versioned token from a matching preview; stale intent is refused without retry."},{"name":"--reasoning-default","value":"string","summary":"Repeat MODEL=EFFORT to replace the full Droid defaults map; exact model IDs, duplicate keys refused."},{"name":"--clear-reasoning-defaults","value":"boolean","summary":"Droid apply/overwrite only: send an empty map, distinct from omitted inherited defaults."}],
mutates: true,
json: "payload",
- details: ["A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview.","Droid map omission preserves inheritance; supplied entries replace the full map, clear sends {}. Only Droid apply/overwrite accepts these options."],
+ details: ["An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview.","Droid map omission preserves inheritance; supplied entries replace the full map, clear sends {}. Only Droid apply/overwrite accepts these options."],
},
{
command: ["integration","client","disable"],
@@ -99,7 +99,7 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [
flags: [{"name":"--client","value":"string","summary":"Explicit file integration ID.","required":true},{"name":"--profile","value":"number","summary":"Aside nonnegative integer account ID; requires --client aside."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."},{"name":"--plan-fingerprint","value":"string","summary":"Optional versioned token from a matching preview; stale intent is refused without retry."}],
mutates: true,
json: "payload",
- details: ["A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview."],
+ details: ["An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview."],
},
{
command: ["integration","client","restore"],
diff --git a/src/cli/capabilities-route-to.ts b/src/cli/capabilities-route-to.ts
deleted file mode 100644
index 002883519b3..00000000000
--- a/src/cli/capabilities-route-to.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Capability, CapabilityFlag } from "./capability-types";
-
-const device: CapabilityFlag = { name: "--device", value: "string", summary: "Home: alias, id, all or this. Child: this or its own link id only." };
-const json: CapabilityFlag = { name: "--json", value: "boolean", summary: "Emit the ocx.route-to/1 status matrix." };
-const overwrite: CapabilityFlag = { name: "--overwrite-conflict", value: "boolean", summary: "Explicitly permit replacing conflicting managed blocks." };
-const local: CapabilityFlag = { name: "--local", value: "boolean", summary: "Child only: opt out with off, or clear the opt-out with inherit." };
-
-export const ROUTE_TO_CAPABILITIES: readonly Capability[] = [
- {
- command: ["route", "to"],
- usage: "ocx route to [--device ] [--accounts ] [--model-names ] [--overwrite-conflict] [--local] [--json]",
- summary: "Set Home-default or per-device route intent, or opt out locally on a Child.",
- routes: [{ method: "PUT", path: "/api/route-to" }],
- flags: [device, json, overwrite, local,
- { name: "--accounts", value: "string", summary: "Aside on only: all, none, or comma-separated stable userIds; never u/N slots." },
- { name: "--model-names", value: "string", summary: "Hermes/Command Code on only: alias or routed model names." },
- ],
- mutates: true, json: "payload",
- details: ["Clients: codex, claude, claude-desktop, grok and the supported file integrations. Without --device a Home updates its default and materializes itself; all is only for status/sync.",
- "inherit requires --device unless clearing a Child --local opt-out. Child writes print the exact Home command with the Child link id; --local supports off/inherit only. --overwrite-conflict applies only to on or sync.",
- "On the Home, codex, claude, grok and claude-desktop follow the Home's own native switches: a Home default or --device home write for them writes that switch (Home inherit sets it to the default)."],
- },
- {
- command: ["route", "to", "status"],
- usage: "ocx route to status [--device ] [--json]",
- summary: "Read per-device route intent, its source and actual client apply outcomes.",
- routes: [{ method: "GET", path: "/api/route-to" }],
- flags: [device, json], mutates: false, json: "payload",
- details: ["On a Child this reads local receipts without a Home management relay. Intent and applied state are distinct; null applied means no recorded outcome.",
- "Sources: builtin, default, device, local (Child opt-out) and native (a Home row governed by the Home's own switch). An outcome recorded for an earlier intent reads stale."],
- },
- {
- command: ["route", "to", "sync"],
- usage: "ocx route to sync [--device ] [--overwrite-conflict] [--json]",
- summary: "Reapply route intent on selected devices, or locally on a Child.",
- routes: [{ method: "POST", path: "/api/route-to/sync" }],
- flags: [device, overwrite, json], mutates: true, json: "payload",
- details: ["No conflict retry or implicit overwrite. Child sync calls its local materializer and sends no Home management write.",
- "Home sync never changes the Home's native switches for codex, claude, grok or claude-desktop; it refreshes what each switch says."],
- },
-];
diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts
index 8f1dda7f061..7a3c82376e7 100644
--- a/src/cli/capabilities.ts
+++ b/src/cli/capabilities.ts
@@ -13,7 +13,6 @@ import { INTEGRATION_CAPABILITIES } from "./capabilities-integrations";
import { OBSERVE_SYSTEM_CAPABILITIES } from "./capabilities-observe-system";
import { ACCESS_REMOTE_CAPABILITIES } from "./capabilities-access-remote";
import { LAB_CAPABILITIES } from "./capabilities-lab";
-import { ROUTE_TO_CAPABILITIES } from "./capabilities-route-to";
export const CAPABILITIES: readonly Capability[] = [
...BASE_CAPABILITIES,
@@ -24,7 +23,6 @@ export const CAPABILITIES: readonly Capability[] = [
...OBSERVE_SYSTEM_CAPABILITIES,
...ACCESS_REMOTE_CAPABILITIES,
...LAB_CAPABILITIES,
- ...ROUTE_TO_CAPABILITIES,
];
export { HEAD_CAPABILITIES } from "./capabilities-base";
diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts
index 5e668fa7cdc..209c4a05c15 100644
--- a/src/cli/claude-desktop.ts
+++ b/src/cli/claude-desktop.ts
@@ -7,12 +7,11 @@ import { resolve } from "node:path";
import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths";
import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config";
import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state";
-import { setHomeIntegrationEnabled } from "../routing/home-route-capture";
import { readClientConnectionState, assertClientConnectionUnchanged, assertNoClientDisconnectPending, type ClientConnectionState } from "../client/state";
import { downloadDesktop3pModels, HubClientError, normalizeHubOrigin } from "../client/hub-client";
import { readServiceApiTokenState } from "../lib/service-secrets";
import { applyRemoteDesktopStore, type DesktopStoreResult } from "../claude/desktop-remote-store";
-import { assertClientLifecycleHeld, withClientLifecycle, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
+import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
import {
DESKTOP_FAMILIES,
moveDesktopRoute,
@@ -306,16 +305,12 @@ async function applyConnectedDesktopProfile(
}
/** Persist the operator's Desktop mode choice; a failed marker save never undoes the apply. */
-async function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps, held?: ClientLifecycleHeld): Promise {
+async function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): Promise {
try {
- return await settleConfigRoot(configRoot(), () => {
- const save = () => {
- const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode));
- return outcome.status !== "unavailable";
- };
- if (held) { assertClientLifecycleHeld(held); return save(); }
- return withClientLifecycleSync(save, deps.lifecycleLockDeps);
- });
+ return await settleConfigRoot(configRoot(), () => withClientLifecycleSync(() => {
+ const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode));
+ return outcome.status !== "unavailable";
+ }, deps.lifecycleLockDeps));
} catch {
return false;
}
@@ -440,21 +435,8 @@ async function applyFirstPartyDesktop(
return { ok: false, path: "", reason: error instanceof Error ? error.message : "daemon apply failed" };
}
}
- // The offline path has no picker controller to serialize CLI/server writers. Keep L
- // across settings, gateway retirement, mode publication and any conditional rollback.
- try { return await withClientLifecycle(held => applyFirstPartyDesktopOffline(deps, held), deps.lifecycleLockDeps); }
- catch (error) {
- return { ok: false, path: "", reason: error instanceof Error && error.message === "client_lifecycle_busy"
- ? "client_lifecycle_busy" : "desktop_apply_failed" };
- }
-}
-
-async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: ClientLifecycleHeld): Promise {
- assertClientLifecycleHeld(held);
- assertNoClientDisconnectPending();
- if (readClientConnectionState().kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" };
const config = loadConfig();
- const desired = await withFirstPartySecretsSettled(getConfigDir(), () => setHomeIntegrationEnabled("claude-desktop", true));
+ const desired = await withFirstPartySecretsSettled(getConfigDir(), () => setIntegrationEnabled("claude-desktop", true));
if (!desired.ok) return "path" in desired ? desired : { ok: false, path: "", reason: desired.message };
// Establish the replacement before deleting the working gateway. A refused
// cleanup restores only our managed env keys, preserving unrelated settings.
@@ -470,8 +452,7 @@ async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: Clien
const library = inspectDesktop3pConfigLibrary({ appliedFingerprint });
if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") {
let removed: ReturnType;
- try { removed = await settleConfigRoot(configRoot(), () => removeDesktop3pStandardPivot({ appliedFingerprint: readConfigForAdmittedMutation().claudeCode?.desktopProfile?.appliedFingerprint ?? null, replaceWhileEnabled: true,
- lifecycleLockDeps: deps.lifecycleLockDeps, lifecycleHeld: held })); }
+ try { removed = await settleConfigRoot(configRoot(), () => removeDesktop3pStandardPivot({ appliedFingerprint: readConfigForAdmittedMutation().claudeCode?.desktopProfile?.appliedFingerprint ?? null, replaceWhileEnabled: true })); }
catch (error) {
removed = { ok: false, changed: false, kind: "write_failed", libraryPath: library.libraryPath,
reason: error instanceof WindowsSecretAclSettleTimeoutError ? "acl_settlement_timeout" : "desktop_mutation_failed" };
@@ -479,7 +460,7 @@ async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: Clien
gatewayRemoved = removed.changed;
if (!removed.ok) {
const restored = removed.changed || !applied.changed || await rollbackDesktopFirstPartySettled(getConfigDir(), rollback).catch(() => false);
- const modeSaved = !removed.changed || await saveDesktopMode("first-party", deps, held);
+ const modeSaved = !removed.changed || await saveDesktopMode("first-party", deps);
const warning = [restored ? "" : "first-party settings rollback did not complete",
modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; ");
return { ok: false, path: library.selectedProfilePath ?? "", reason: removed.kind === "cleanup_incomplete" ? "gateway_cleanup_incomplete" : `gateway_profile_active:${removed.reason ?? removed.kind}`,
@@ -487,7 +468,7 @@ async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: Clien
...(warning ? { warning } : {}) };
}
}
- const saved = await saveDesktopMode("first-party", deps, held);
+ const saved = await saveDesktopMode("first-party", deps);
modeCommitted = saved;
return {
ok: true,
@@ -540,7 +521,7 @@ export async function applyProfile(
// so a process crash cannot leave a gateway profile that startup immediately removes.
let committedIntent = false, savedProfile = false, gatewayApplied = false;
try {
- const desired = await settleConfigRoot(configRoot(), () => setHomeIntegrationEnabled("claude-desktop", true));
+ const desired = await settleConfigRoot(configRoot(), () => setIntegrationEnabled("claude-desktop", true));
if (!desired.ok) return { ok: false, path: "", reason: desired.message };
committedIntent = true;
const config = loadConfig();
diff --git a/src/cli/connect.ts b/src/cli/connect.ts
index 5271a540108..b3986f7df24 100644
--- a/src/cli/connect.ts
+++ b/src/cli/connect.ts
@@ -26,7 +26,6 @@ import { createSshRunner, type SshRunner } from "../link/ssh-runner";
import type { OrphanTunnelResult } from "../client/link-tunnel";
import { inspectRemoteDesktopStore } from "../claude/desktop-remote-store";
import type { OcxConnectedClientId } from "../types";
-import { ROUTE_CLIENT_IDS, isRouteClientId } from "../link/route-intent";
import {
CliUsageError,
csv,
@@ -241,8 +240,8 @@ export function collectClientConnectionStatus(
function parseClients(raw: string | undefined): OcxConnectedClientId[] {
const values = csv(raw) ?? ["codex", "claude"];
- if (values.length < 1 || values.some(value => !isRouteClientId(value))) {
- throw new CliUsageError(`--clients must contain route client ids: ${ROUTE_CLIENT_IDS.join(", ")}`, CONNECT_USAGE);
+ if (values.length < 1 || values.some(value => value !== "codex" && value !== "claude")) {
+ throw new CliUsageError("--clients must contain codex and/or claude", CONNECT_USAGE);
}
return values as OcxConnectedClientId[];
}
@@ -391,9 +390,6 @@ async function runHubConnect(argv: string[], deps: ClientCommandDeps): Promise client !== "codex" && client !== "claude")) {
- throw new CliUsageError("Hub connections support only codex and claude; use Link for other clients", CONNECT_USAGE);
- }
const catalogTimeoutSeconds = takeIntegerOption(args, "--catalog-timeout", { min: 1 });
if (catalogTimeoutSeconds !== undefined && catalogTimeoutSeconds > 120) {
throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE);
@@ -435,9 +431,9 @@ async function runHubConnect(argv: string[], deps: ClientCommandDeps): Promise 120) {
throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE);
@@ -473,7 +468,6 @@ async function runLinkConnect(argv: string[], deps: ClientCommandDeps): Promise<
managementUrl: `http://127.0.0.1:${rawPort}`,
credential: { kind: "link", ...credential },
selectedClients: clients,
- selectedClientsExplicit: rawClients !== undefined,
managementTransport: "direct",
transport: "link",
link: { tunnelPort: rawPort, linkId },
@@ -487,9 +481,9 @@ async function runLinkConnect(argv: string[], deps: ClientCommandDeps): Promise<
throw terminalSafeError(error);
});
const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {});
- const report = connectCompletionReport(connection, connection.selectedClients, readiness);
+ const report = connectCompletionReport(connection, clients, readiness);
for (const line of report.lines) console.log(line);
- printCodexShimReadiness(connection.selectedClients);
+ printCodexShimReadiness(clients);
if (report.failure) throw new Error(report.failure);
}
@@ -544,10 +538,8 @@ export async function handleDisconnectCommand(
const keepCatalog = takeFlag(args, "--keep-catalog");
const wantsJson = takeFlag(args, "--json");
const orphanedToken = takeFlag(args, "--orphaned-token");
- const recoverConfig = takeFlag(args, "--recover-config");
const yes = takeFlag(args, "--yes");
rejectArgs(args, DISCONNECT_USAGE, { redactValues: true });
- if (recoverConfig && (!yes || orphanedToken)) throw new CliUsageError("--recover-config requires --yes and cannot be combined with --orphaned-token", DISCONNECT_USAGE);
if (orphanedToken) {
if (!yes || keepCatalog) throw new CliUsageError("--orphaned-token requires --yes and cannot be combined with --keep-catalog", DISCONNECT_USAGE);
const backup = await recoverOrphanedTokens({ ...deps.orphanRecoveryDeps, lifecycleLockDeps: deps.lifecycleLockDeps });
@@ -558,7 +550,7 @@ export async function handleDisconnectCommand(
]);
return;
}
- if (yes && !recoverConfig) throw new CliUsageError("--yes requires --orphaned-token or --recover-config", DISCONNECT_USAGE);
+ if (yes) throw new CliUsageError("--yes requires --orphaned-token", DISCONNECT_USAGE);
const teardown = await teardownClientLink({
readSidecar: readClientLinkState,
connectedLinkId: () => {
@@ -572,14 +564,7 @@ export async function handleDisconnectCommand(
knownHostsFile: deps.linkTeardownDeps?.knownHostsFile ?? linkKnownHostsPath(),
timeoutMs: deps.linkTeardownDeps?.timeoutMs,
});
- const result = await disconnectClient({ keepCatalog, recoverConfig }, deps);
- if (result.nativeIntentOnly) {
- printData(result, wantsJson, [
- "Not connected; restored the standalone native switches an interrupted Remote Link enrollment had changed.",
- ...(result.tokenRemoved ? ["Removed the service token that enrollment left behind."] : []),
- ]);
- return;
- }
+ const result = await disconnectClient({ keepCatalog }, deps);
const payload = {
...result,
homeRevoke: teardown.homeRevoke,
@@ -591,7 +576,6 @@ export async function handleDisconnectCommand(
};
printData(payload, wantsJson, [
"Disconnected locally; native Codex state was restored.",
- ...(result.recoveryBackupDir ? [`Codex recovery snapshot: ${result.recoveryBackupDir}/snapshot.json (private; retained for manual inspection).`] : []),
...(result.desktopRestoration === "standard_fallback"
? ["Previous Desktop settings were not recorded; the managed profile was switched to standard mode."] : []),
...(result.restartRequired ? ["Fully quit and reopen Claude Desktop; local cleanup cannot revoke an in-memory credential."] : []),
diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts
index dba1a4abb35..1a9fd889871 100644
--- a/src/cli/dispatch.ts
+++ b/src/cli/dispatch.ts
@@ -21,11 +21,11 @@ import { printUnknownCommand } from "./help-recovery";
import {
HUB_GATED_SKIP_MESSAGE,
localClientSkipMessage,
+ setIntegrationEnabled,
shouldSyncCodexOnStart,
type LocalClientSkipReason,
} from "../codex/desired-state";
import { siblingSkipMessage } from "../codex/sibling-start";
-import { setHomeIntegrationEnabled, type HomeIntegrationResult } from "../routing/home-route-capture";
import { syncModelsToCodex } from "../codex/sync";
import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
import { restoreNativeCodexAsync, type CodexNativeRestoreResult } from "../codex/inject";
@@ -89,12 +89,6 @@ export function selectDefaultGuiUrl(
return `http://${guiHost}:${live?.port ?? config.port ?? 10100}`;
}
-/** An unavailable route-intent file names the file to repair; the other refusals keep their reason. */
-function desiredStateRefusal(desired: Extract): string {
- return desired.reason === "unavailable" ? `Codex desired state was not saved (unavailable): ${desired.message}`
- : `Codex desired state was not saved (${desired.reason}).`;
-}
-
const commandRunners: Record = {
init: async () => {
const { runInit } = await import("./init");
@@ -179,13 +173,13 @@ const commandRunners: Record = {
if (!live) {
return emitBack(false, "No running proxy found. Run 'ocx start' — it injects opencodex automatically.", 1);
}
- const desired = setHomeIntegrationEnabled("codex", true);
+ const desired = setIntegrationEnabled("codex", true);
if (!desired.ok) {
- return emitBack(false, desiredStateRefusal(desired), desired.reason === "conflict" ? 2 : 1);
+ return emitBack(false, `Codex desired state was not saved (${desired.reason}).`, desired.reason === "conflict" ? 2 : 1);
}
const synced = await syncModelsToCodex(live.port);
if (synced.status === "skipped") {
- // `setHomeIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and
+ // `setIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and
// is not a competing writer either — on a hub it is the role gate. Telling the operator
// to "retry after the competing integration change finishes" sent them waiting for a
// writer that does not exist (#4236).
@@ -206,15 +200,15 @@ const commandRunners: Record = {
if (removeProviderTable && !restoreJson) {
console.log("⚠️ Removing [model_providers.opencodex] means conversations already tagged opencodex will stop opening.");
}
- const desired = setHomeIntegrationEnabled("codex", false);
+ const desired = setIntegrationEnabled("codex", false);
if (!desired.ok) {
if (restoreJson) {
// Machine-readable contract: every restore --json outcome emits one
// schema-complete envelope on stdout, including pre-machinery failures.
const { skippedRestoreEnvelope } = await import("../codex/inject");
- console.log(JSON.stringify(skippedRestoreEnvelope(false, desiredStateRefusal(desired))));
+ console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`)));
} else {
- console.error(desiredStateRefusal(desired));
+ console.error(`Codex desired state was not saved (${desired.reason}).`);
}
return desired.reason === "conflict" ? 2 : 1;
}
@@ -758,12 +752,8 @@ const commandRunners: Record = {
return await handleCompanionCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy });
},
route: async deps => {
- if (deps.args[1] === "to") {
- const { handleRouteToCommand } = await import("./route-to");
- return await handleRouteToCommand(deps.args.slice(2), { findLiveProxy: deps.findLiveProxy });
- }
if (deps.args[1] !== "combo" && deps.args[1] !== "policy") {
- console.error("Usage: ocx route ");
+ console.error("Usage: ocx route ");
return 2;
}
if (deps.args[1] === "combo") {
diff --git a/src/cli/help-catalog.ts b/src/cli/help-catalog.ts
index 7743c2e0510..df29a851a4e 100644
--- a/src/cli/help-catalog.ts
+++ b/src/cli/help-catalog.ts
@@ -1,6 +1,5 @@
import { CAPABILITIES, type Capability } from "./capabilities";
import { CLI_COMMANDS, findCommand, type CliCommandEntry } from "./registry";
-import { EXPORT_CLIENT_IDS } from "../clients/client-ids";
export type HelpResolution =
| { kind: "entry"; entry: CliCommandEntry; canonicalName: string; capability?: Capability; children: readonly Capability[] }
@@ -89,16 +88,6 @@ export function resolveHelpPath(requested: readonly string[]): HelpResolution {
const capability = declared.find(candidate => candidate.command.length === path.length && startsWithPath(candidate.command, path));
const children = declared.filter(candidate => candidate.command.length > path.length && startsWithPath(candidate.command, path));
if (capability) return { kind: "capability", path, capability, children };
- // Clients and modes are operands of one capability, not registry leaves. Resolve their
- // help without multiplying the generated operating surface by every client/action pair.
- if (path[0] === "route" && path[1] === "to" && path.length >= 3
- && ["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS].includes(path[2])) {
- const owner = declared.find(candidate => candidate.command.join(" ") === "route to");
- if (owner && path.length === 3) return { kind: "prefix", path, children: ["on", "off", "inherit"].map(action => ({ ...owner, command: [...path, action] })) };
- if (owner && path.length === 4 && ["on", "off", "inherit"].includes(path[3])) {
- return { kind: "capability", path, capability: { ...owner, command: path }, children: [] };
- }
- }
if (children.length) return { kind: "prefix", path, children };
for (let length = Math.min(path.length - 1, MAX_DOCUMENTED_DEPTH); length > 1; length--) {
diff --git a/src/cli/help.ts b/src/cli/help.ts
index d86cc8464e2..67780c76753 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -86,7 +86,7 @@ Usage:
ocx fallback Turn model or provider fallback off per scope; per-device local fallback
ocx observe Logs, usage, storage, memory, and debug data
ocx inspect Effective config, catalog, analytics, pacing, client-config
- ocx route Routing features (to, combo, policy)
+ ocx route Routing features (combo, policy)
ocx logs [filters] Alias of ocx observe logs
ocx usage [--range ] [--provider ] [--model ] [--top <1-1000>]
Token and estimated-cost report (alias of ocx observe usage)
diff --git a/src/cli/inspect.ts b/src/cli/inspect.ts
index 8f8a85f8f09..4cabb0ceae8 100644
--- a/src/cli/inspect.ts
+++ b/src/cli/inspect.ts
@@ -109,17 +109,8 @@ async function nativeIntegration(argv: string[], deps: RuntimeApiDeps): Promise<
throw new CliUsageError(`expected on or off after ${action}`, USAGE);
}
- // A native switch is a Home-only decision. The native route (/api/native-integrations/)
- // writes the Home's switch, records no Home route intent, and runs the client's own transition
- // (Claude Desktop first-party included); Children keep inheriting their saved default. A Child
- // refuses here with the Home command for its own device.
- const { readLinkChildContext } = await import("./link-child");
- const routeDeps = deps as import("./route-to").RouteToDeps;
- if ((routeDeps.readChildContext ?? readLinkChildContext)()) {
- const { routeToAction } = await import("./route-to");
- await routeToAction([action, state, ...(wantsJson ? ["--json"] : [])], routeDeps);
- return;
- }
+ // Toggling rewrites the client's own config file, which is why each client has its own route
+ // rather than one route with a client parameter.
const result = await runtimeRequest(`/api/native-integrations/${action}`, {
method: "PUT",
headers: { "content-type": "application/json" },
diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts
index 82e5eb9ffa2..09fa2fb392c 100644
--- a/src/cli/integrations.ts
+++ b/src/cli/integrations.ts
@@ -15,7 +15,6 @@ import {
import { clientIntegrationPath, validateAsideProfile } from "./integration-input";
import { asideProfileRecoveryLines } from "./aside-profile-recovery";
import type { IntegrationClientId } from "../integrations/registry";
-import type { RouteToDeps } from "./route-to";
const CLAUDE_USAGE = `Usage:
ocx claude config [status] [--json]
@@ -241,31 +240,10 @@ export async function handleClientIntegrationCommand(
const { handleIntegrationAsideSync } = await import("./integration-aside-sync");
return handleIntegrationAsideSync(argv.slice(1), deps);
}
- // Enable and disable are the Home's own decision, like the dashboard toggle: the server records
- // it so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off
- // changes those). On a Child the Home decides, so a plain toggle goes to route-to, which refuses
- // and prints the Home command.
- const toggle = ["enable", "disable"].includes(argv[0] ?? "");
- if (toggle && !argv.some(arg => ["--profile", "--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults"]
- .some(flag => arg === flag || arg.startsWith(`${flag}=`)))) {
- const { readLinkChildContext } = await import("./link-child");
- if (((deps as RouteToDeps).readChildContext ?? readLinkChildContext)()) {
- return runCliAction(async () => {
- const { integrationOption } = await import("./integration-input");
- const { routeToAction } = await import("./route-to");
- const args = argv.slice(1);
- const client = integrationOption(args, "--client");
- if (!client) throw new CliUsageError("--client is required", CLIENT_USAGE);
- await routeToAction([client, argv[0] === "enable" ? "on" : "off", ...args], deps);
- });
- }
- }
if (argv[0] === "preview" || argv.some(arg => ["--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults", "--model-names"]
.some(flag => arg === flag || arg.startsWith(`${flag}=`)))) {
const { handleIntegrationPreviewCommand } = await import("./integration-preview");
- const code = await handleIntegrationPreviewCommand(argv, deps, expectedClientId);
- if (code === 0 && toggle) console.error(HOME_ONLY_NOTE);
- return code;
+ return handleIntegrationPreviewCommand(argv, deps, expectedClientId);
}
return runCliAction(async () => {
const args = [...argv];
@@ -379,12 +357,9 @@ export async function handleClientIntegrationCommand(
? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}${row.residual === true ? " Recovery did not finish." : ""}${typeof row.snapshotPath === "string" ? ` Backup: ${row.snapshotPath}` : ""}`)
: [String(batch.message ?? `${client} ${action}d.`)]);
if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result);
- console.error(HOME_ONLY_NOTE);
});
}
-const HOME_ONLY_NOTE = "This changed this machine only; linked Children keep their route defaults (ocx route to on|off changes those).";
-
export const INTEGRATION_USAGE = { claude: CLAUDE_USAGE, grok: GROK_USAGE, client: CLIENT_USAGE };
const ZCODE_USAGE = `Usage:
diff --git a/src/cli/link.ts b/src/cli/link.ts
index a35921f6856..c7591093281 100644
--- a/src/cli/link.ts
+++ b/src/cli/link.ts
@@ -7,7 +7,6 @@ import {
import { TUNNEL_CAUSES } from "../link/tunnel-history";
import { isEpochMs, type LinkChildParity } from "../link/child-parity";
import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings";
-import { isRouteClientId, parseRouteClientOutcomes, ROUTE_INTENT_DIGEST_PATTERN, routeClientOutcomesForPeer } from "../link/route-intent";
import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility";
import { runLinkEnrollment } from "./link-enrollment";
import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup";
@@ -37,7 +36,7 @@ export const LINK_USAGE = `Usage:
ocx link probe [--json]
ocx link add [--plant] [--expect-fingerprint SHA256:…] [--json]
ocx link port [--json]
- ocx link issue --alias --tunnel-port [--clients ] [--json]
+ ocx link issue --alias --tunnel-port [--json]
ocx link status [--json]
ocx link revoke --link-id [--purge] [--force --yes] [--json]
ocx link reconnect --link-id [--json]
@@ -271,9 +270,6 @@ async function runPort(args: string[], deps: LinkCliDeps): Promise {
async function runIssue(args: string[], deps: LinkCliDeps): Promise {
takeJsonFlag(args);
const alias = takeOption(args, "--alias");
- const rawClients = takeOption(args, "--clients");
- const selectedClients = rawClients?.split(",");
- if (selectedClients && (!selectedClients.length || !selectedClients.every(isRouteClientId) || new Set(selectedClients).size !== selectedClients.length)) throw new CliUsageError("--clients needs unique supported client IDs", LINK_USAGE);
const tunnelPort = takeIntegerOption(args, "--tunnel-port", { min: 1024 });
if (!alias || tunnelPort === undefined || !isLinkPort(tunnelPort)) {
throw new CliUsageError("issue requires --alias and --tunnel-port", LINK_USAGE);
@@ -284,7 +280,7 @@ async function runIssue(args: string[], deps: LinkCliDeps): Promise {
const result = validateIssue(await linkRequest("/api/link/issue", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ alias, tunnelPort, ...(selectedClients ? { selectedClients } : {}) }),
+ body: JSON.stringify({ alias, tunnelPort }),
}, deps));
console.log(JSON.stringify(result));
}
@@ -395,7 +391,7 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}):
});
}
-const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt", "appliedRouteIntentDigest", "routeClientOutcomes"] as const;
+const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const;
const WIRE_STATES = new Set(["connecting", "connected", "reconnecting", "failed", "idle"]);
const CAUSES = new Set(TUNNEL_CAUSES);
@@ -435,20 +431,10 @@ function validateParity(candidate: Record, index: number): Link
if (digest !== undefined && digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) {
throw new Error(`invalid link API response: link ${index} appliedCodexSettingsDigest`);
}
- let route: Pick = {};
- if (candidate.appliedRouteIntentDigest !== undefined || candidate.routeClientOutcomes !== undefined) {
- const routeDigest = candidate.appliedRouteIntentDigest;
- const outcomes = parseRouteClientOutcomes(candidate.routeClientOutcomes);
- if ((routeDigest !== null && (typeof routeDigest !== "string" || !ROUTE_INTENT_DIGEST_PATTERN.test(routeDigest))) || !outcomes) {
- throw new Error(`invalid link API response: link ${index} route intent`);
- }
- route = { appliedRouteIntentDigest: routeDigest, routeClientOutcomes: routeClientOutcomesForPeer(outcomes) };
- }
return {
lastApplyAt: time("lastApplyAt"),
runtimeStartedAt: time("runtimeStartedAt"),
appliedCodexSettingsDigest: typeof digest === "string" ? digest : null,
reportedAt: time("reportedAt"),
- ...route,
};
}
diff --git a/src/cli/registry.ts b/src/cli/registry.ts
index 0cb96d09997..a9f48283878 100644
--- a/src/cli/registry.ts
+++ b/src/cli/registry.ts
@@ -178,12 +178,12 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
},
{
name: "disconnect",
- usage: "ocx disconnect [--keep-catalog] [--recover-config --yes] [--json]",
+ usage: "ocx disconnect [--keep-catalog] [--json]",
summary: "Restore local client state offline and clear the remote-hub connection.",
},
{
name: "sync",
- usage: "ocx sync [--expect-link | --expect-hub-key ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]",
+ usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]",
summary: "Fetch provider models and inject them into Codex config.",
details: [
"A current Home catalog fans out to linked Children; --local-only disables fan-out and --json emits the result. Partial fan-out exits 6.",
@@ -431,8 +431,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
},
{
name: "route",
- usage: "ocx route ",
- summary: "Manage combo routing, routing profiles and per-device client intent.",
+ usage: "ocx route ",
+ summary: "Manage combo routing and routing profiles.",
},
{
name: "effort",
diff --git a/src/cli/route-to.ts b/src/cli/route-to.ts
deleted file mode 100644
index 4627ad94374..00000000000
--- a/src/cli/route-to.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import {
- CliUsageError, RuntimeApiError, printData, rejectArgs, runCliAction, runtimeRequest,
- type RuntimeApiDeps,
-} from "./runtime-api";
-import { integrationFlag, integrationOption } from "./integration-input";
-import { readLinkChildContext, linkChildRefusal, type LinkChildContext } from "./link-child";
-import { isRouteClientId, parseRouteClientIntent } from "../link/route-intent";
-import type { RouteToStatus } from "../routing/route-to-status";
-
-export const ROUTE_TO_USAGE = `Usage:
- ocx route to status [--device ] [--json]
- ocx route to sync [--device ] [--overwrite-conflict] [--json]
- ocx route to [--device ] [--accounts ] [--model-names ] [--overwrite-conflict] [--json]
- ocx route to --local [--device this] [--json]`;
-
-const loadLocal = () => import("../routing/route-to-local");
-export interface RouteToDeps extends RuntimeApiDeps {
- readChildContext?: () => LinkChildContext | null;
- loadLocal?: typeof loadLocal;
-}
-
-export function routeToStatusLines(status: RouteToStatus): string[] {
- return ["DEVICE CLIENT INTENT SOURCE APPLIED TARGET", ...status.devices.flatMap(device =>
- device.routeIntent === "unsupported" ? [`${device.alias || device.id} route intent unsupported (${device.reason ?? "unsupported"})`]
- : device.clients.map(row => [device.alias || device.id, row.client, row.intent, row.source,
- row.applied ?? "-", row.target ?? "-"].join(" "))),
- ...(status.syncs ?? []).map(row => `sync ${row.id}: ${row.state}${row.reason ? ` (${row.reason})` : ""}`),
- ...(status.deferred ? [`Local reconciliation deferred: Home intent or the Child catalog is unavailable${status.deferred.clients.length ? ` (${status.deferred.clients.join(", ")})` : ""}.`] : [])];
-}
-
-function accountsOption(raw: string | undefined): "all" | "none" | string[] | undefined {
- if (raw === undefined || raw === "all" || raw === "none") return raw;
- const values = raw.split(",").map(value => value.trim());
- if (!parseRouteClientIntent({ enabled: true, accounts: values }, "aside")
- || values.some(value => value === "all" || value === "none")) {
- throw new CliUsageError("--accounts requires all, none, or unique Aside userIds (not u/N slots)", ROUTE_TO_USAGE);
- }
- return values;
-}
-
-/** Shared with legacy toggle aliases, inside their existing error boundary. */
-export async function routeToAction(argv: string[], deps: RouteToDeps = {}): Promise {
- const args = [...argv];
- const json = integrationFlag(args, "--json");
- const local = integrationFlag(args, "--local");
- const overwriteConflict = integrationFlag(args, "--overwrite-conflict");
- const device = integrationOption(args, "--device");
- const accounts = accountsOption(integrationOption(args, "--accounts"));
- const modelNames = integrationOption(args, "--model-names");
- const subject = args.shift() ?? "status";
- const read = subject === "status";
- const sync = subject === "sync";
- const client = read || sync ? undefined : subject;
- const action = client === undefined ? undefined : args.shift();
- rejectArgs(args, ROUTE_TO_USAGE);
- if (device !== undefined && (!device.trim() || /[\x00-\x20\x7f]/.test(device))) {
- throw new CliUsageError("--device requires an alias, id, all or this", ROUTE_TO_USAGE);
- }
- if (client !== undefined && !isRouteClientId(client)) {
- throw new CliUsageError("Expected a supported route-to client", ROUTE_TO_USAGE);
- }
- if (client !== undefined && !["on", "off", "inherit"].includes(action ?? "")) {
- throw new CliUsageError("Expected a supported client and on, off or inherit", ROUTE_TO_USAGE);
- }
- if (client !== undefined && device === "all") {
- throw new CliUsageError("--device all applies only to status or sync; omit --device to change the Home default", ROUTE_TO_USAGE);
- }
- if (modelNames !== undefined && (modelNames !== "alias" && modelNames !== "routed")) {
- throw new CliUsageError("--model-names must be alias or routed", ROUTE_TO_USAGE);
- }
- if (accounts !== undefined && (client !== "aside" || action !== "on")) {
- throw new CliUsageError("--accounts requires aside on", ROUTE_TO_USAGE);
- }
- if (modelNames !== undefined && (action !== "on" || (client !== "hermes" && client !== "commandcode"))) {
- throw new CliUsageError("--model-names requires hermes or commandcode on", ROUTE_TO_USAGE);
- }
- if (overwriteConflict && !sync && action !== "on") {
- throw new CliUsageError("--overwrite-conflict applies only to on or sync", ROUTE_TO_USAGE);
- }
- if (local && (client === undefined || !["off", "inherit"].includes(action ?? "") || overwriteConflict)) {
- throw new CliUsageError("--local supports only client off or inherit", ROUTE_TO_USAGE);
- }
- if (action === "inherit" && device === undefined && !local) {
- throw new CliUsageError("inherit requires --device or --local", ROUTE_TO_USAGE);
- }
-
- // Detect Child before runtimeRequest: these operations must never enter its Home relay.
- const child = (deps.readChildContext ?? readLinkChildContext)();
- let result: unknown;
- let requestFailure: RuntimeApiError | undefined;
- if (child) {
- if (device !== undefined && device !== "this" && device !== child.linkId) {
- throw new CliUsageError("A Child can target only --device this or its own link id", ROUTE_TO_USAGE);
- }
- if (client !== undefined && !local) {
- const command = ["ocx route to", client, action, "--device", child.linkId,
- ...(accounts === undefined ? [] : ["--accounts", Array.isArray(accounts) ? accounts.join(",") : accounts]),
- ...(modelNames === undefined ? [] : ["--model-names", modelNames]),
- ...(overwriteConflict ? ["--overwrite-conflict"] : [])].join(" ");
- throw new RuntimeApiError(`${linkChildRefusal(child)}\nRun on the Home: ${command}`, 409, null);
- }
- const api = await (deps.loadLocal ?? loadLocal)();
- result = read ? await api.readLocalRouteToStatus(child)
- : sync ? await api.syncLocalRouteTo(child, overwriteConflict)
- : await api.setLocalRouteToOff(child, client!, action === "off");
- } else {
- if (local) throw new CliUsageError("--local requires a Remote Link Child", ROUTE_TO_USAGE);
- const body = { ...(device === undefined ? {} : { device }),
- ...(overwriteConflict ? { overwriteConflict: true } : {}) };
- try {
- result = read ? await runtimeRequest(`/api/route-to${device === undefined ? "" : `?device=${encodeURIComponent(device)}`}`, { redirect: "error" }, deps)
- : await runtimeRequest(sync ? "/api/route-to/sync" : "/api/route-to", {
- method: sync ? "POST" : "PUT", redirect: "error",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(sync ? body : { client, action, ...body,
- ...(accounts === undefined ? {} : { accounts }), ...(modelNames === undefined ? {} : { modelNames }) }),
- }, deps);
- } catch (error) {
- if (!(error instanceof RuntimeApiError) || !error.body || typeof error.body !== "object"
- || !("schema" in error.body) || error.body.schema !== "ocx.route-to/1") throw error;
- requestFailure = error;
- result = error.body;
- }
- }
- const status = result as RouteToStatus;
- if (!status || status.schema !== "ocx.route-to/1" || !Array.isArray(status.devices) || status.devices.length === 0
- || (status.deferred !== undefined && (!status.deferred || status.deferred.reason !== "intent_or_catalog_pending"
- || !Array.isArray(status.deferred.clients) || status.deferred.clients.some(client => !isRouteClientId(client))))
- || (status.syncs !== undefined && (!Array.isArray(status.syncs) || status.syncs.some(row => !row || typeof row.id !== "string"
- || !["synced", "failed", "pending", "unsupported"].includes(row.state) || (row.reason !== undefined && typeof row.reason !== "string"))))
- || status.devices.some(row => !row || typeof row.id !== "string" || !Array.isArray(row.clients)
- || (row.routeIntent === undefined ? row.clients.length === 0
- : row.routeIntent !== "unsupported" || row.clients.length !== 0 || typeof row.reason !== "string")
- || (row.alias !== undefined && typeof row.alias !== "string")
- || row.clients.some(client => !client || !isRouteClientId(client.client)
- || (client.intent !== "on" && client.intent !== "off")
- || !["builtin", "default", "device", "native", "local"].includes(client.source)
- || (client.applied !== null && !["current", "stale", "not_installed", "conflict", "failed"].includes(client.applied))
- || (client.target !== undefined && typeof client.target !== "string")))) {
- throw new RuntimeApiError("Invalid route-to status response; outcome is unverified. Read status before retrying.", 502, null);
- }
- if (!read) {
- const requested = child?.linkId ?? (device === undefined || device === "this" ? "home" : device);
- const matching = status.devices.filter(row => row.id === requested || row.alias === requested);
- if ((requested !== "all" && matching.length !== 1)
- || (client !== undefined && !matching[0]?.clients.some(row => row.client === client))) {
- throw new RuntimeApiError("Invalid route-to status response; requested device/client is unverified. Read status before retrying.", 502, null);
- }
- }
- printData(status, json, routeToStatusLines(status));
- if (requestFailure) throw requestFailure;
- if (status.syncs?.some(row => row.state === "failed")) {
- throw new RuntimeApiError("Route sync failed for one or more devices. Read status before retrying.", 502, null);
- }
- if (sync && status.deferred) {
- throw new RuntimeApiError("Local route sync is deferred; not all client configurations were reconciled. Retry when the Home and Child catalog are available.", 503, null);
- }
- const failedClients = status.devices.flatMap(row => row.clients.filter(entry => entry.applied === "failed" || entry.applied === "conflict"));
- if (!read && failedClients.some(entry => client === undefined || entry.client === client)) {
- throw new RuntimeApiError("Route intent returned unresolved client failures or conflicts. Read status before retrying.", 409, null);
- }
- if (!read && client !== undefined && failedClients.length > 0) {
- console.error("Route status includes unresolved failures or conflicts for other clients; review their status.");
- }
-}
-
-export function handleRouteToCommand(argv: string[], deps: RouteToDeps = {}): Promise {
- return runCliAction(() => routeToAction(argv, deps));
-}
diff --git a/src/cli/sync-command.ts b/src/cli/sync-command.ts
index 5af086d9d59..1aec4298005 100644
--- a/src/cli/sync-command.ts
+++ b/src/cli/sync-command.ts
@@ -50,8 +50,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise {
if (expectation || clientState.kind === "connected") {
try {
const { syncConnectedClient } = await import("../client/connect");
- const result = await syncConnectedClient({ restartCodex, ...(expectation ? { expect: expectation } : {}),
- ...(syncArgs.includes("--overwrite-conflict") ? { overwriteConflict: true, ...(flags.overwriteClient ? { overwriteClient: flags.overwriteClient } : {}) } : {}) });
+ const result = await syncConnectedClient(expectation ? { restartCodex, expect: expectation } : { restartCodex });
if (expectation) {
// The root preflight skipped the shim repair for a pinned sync; the identity held, so
// run it now, as an unpinned sync would have.
@@ -76,13 +75,6 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise {
for (const line of describeHomeCodexSettings(settings.document.settings)) console.log(` ${terminalSafeText(line)}`);
}
}
- if (result.routeIntent) {
- for (const row of result.routeIntent.outcomes) {
- if (row.outcome === "current") continue;
- console.error(`Route to ${row.client}: ${row.outcome}.`);
- if (row.outcome !== "not_installed") process.exitCode = 1;
- }
- }
await handleConnectedSyncCatalogWrite(result, restartScope);
if (result.applyRecordFailed) {
console.error("The Home's Codex settings were applied, but the apply record could not be written; the client runtime was not restarted. Run 'ocx sync' again.");
@@ -199,17 +191,6 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise {
else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`);
}
}
- if (synced.status !== "refused") {
- const { homeRouteIntentInUse, readRouteIntentState } = await import("../routing/route-intent-state");
- if (homeRouteIntentInUse(readRouteIntentState())) {
- const { syncHomeRouteTo } = await import("../routing/route-to-local");
- const { packageVersion } = await import("../lib/package-version");
- const outcomes = await syncHomeRouteTo({ ...deps.loadConfig(), ...(live ? { port: live.port } : {}) }, packageVersion("0.0.0"), syncArgs.includes("--overwrite-conflict"), flags.overwriteClient);
- for (const row of outcomes) {
- if (row.outcome !== "current" && row.outcome !== "not_installed") { console.error(`${row.client}: ${row.outcome}`); code = 1; }
- }
- }
- }
return code || (typeof process.exitCode === "number" ? process.exitCode : 0);
};
let code: number;
@@ -235,10 +216,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise {
return { state: "failed", reason: "platform-probe-failed", exit: null };
}
try {
- // An ephemeral ocx-child gets the plain catalog sync: it never reads route intent, so it
- // has nothing to overwrite and refuses the flags.
- const overwrite = link.childKind !== "ephemeral" && deps.args.includes("--overwrite-conflict");
- const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), argv: remotePinnedSyncArgv(link.id, platform, overwrite, overwrite ? flags.overwriteClient : undefined) }), { timeoutMs: Math.max(0, deadline - Date.now()) });
+ const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), argv: remotePinnedSyncArgv(link.id, platform) }), { timeoutMs: Math.max(0, deadline - Date.now()) });
return { ...classifySyncResult(result), exit: result.code };
} catch (error) {
if (error instanceof FanoutProcessError) return { state: error.reason === "deadline-exceeded" || error.reason === "termination-unconfirmed" ? "timed-out" : "failed", reason: error.reason, exit: null };
diff --git a/src/cli/sync-fanout.ts b/src/cli/sync-fanout.ts
index 587d8257424..bbbcdf487a5 100644
--- a/src/cli/sync-fanout.ts
+++ b/src/cli/sync-fanout.ts
@@ -2,7 +2,6 @@ import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver";
import type { LinkRecord, LinkStore } from "../link/store";
import type { LinkStatusDto } from "../link/status-projection";
import { LinkStatusError } from "./sync-link-status";
-import { ROUTE_OVERWRITE_MIN_CHILD_VERSION } from "../link/remote-windows";
export const REMOTE_LINK_SYNC_MESSAGE = "Remote Link Home catalog synchronized.";
export const STALE_REMOTE_CATALOG_MESSAGE = "Home unavailable; retained and applied the last-known-good remote catalog (stale).";
@@ -51,9 +50,6 @@ export function classifySyncResult(result: { code: number; stdout: string; stder
if (code === 121) {
if (last === "ocx-fanout:child-ocx-lacks-expect-link") return skipped("child-ocx-lacks-expect-link");
if (last === "ocx-fanout:child-ocx-version-unknown") return skipped("child-ocx-version-unknown");
- // --overwrite-conflict was refused before any sync: an older ocx would ignore it.
- if (last === "ocx-fanout:child-ocx-lacks-overwrite") return skipped("child-ocx-lacks-overwrite");
- if (last === "ocx-fanout:child-ephemeral-no-overwrite") return skipped("child-ephemeral-no-overwrite");
return failure("child-sync-failed");
}
const markers = lines.map(line => /^ocx-fanout:selected:(.+)$/.exec(line)).filter(match => match !== null);
@@ -91,8 +87,6 @@ export type FanoutDocument = { schema: "ocx.sync.fanout/1"; home: HomeResult; fa
function childSkipHint(reason: string | null, alias: string): string {
if (reason === "child-ocx-lacks-expect-link") return `Update OpenCodex on ${alias} to >= 2.93.0 using the installer that owns its selected ocx. For Desktop on headless Windows, use Check for Updates in the signed-in Desktop tray.`;
- if (reason === "child-ocx-lacks-overwrite") return `Update OpenCodex on ${alias} to >= ${ROUTE_OVERWRITE_MIN_CHILD_VERSION} to sync with --overwrite-conflict, or sync without it.`;
- if (reason === "child-ephemeral-no-overwrite") return `${alias} runs the ephemeral ocx-child, which has no route intent to overwrite; sync without --overwrite-conflict.`;
if (reason === "child-updating") return "Wait for the Child update to finish, then retry sync.";
if (reason === "child-link-mismatch:pending_operation" || reason === "child-link-mismatch:disconnect_pending") return "Wait for the Child's link operation to finish, then check its link and retry sync.";
if (reason === "child-link-mismatch:disconnected" || reason === "child-link-mismatch:other_link") return "Please re-link this Child to this Home, then retry sync.";
diff --git a/src/cli/sync-flags.ts b/src/cli/sync-flags.ts
index 5dec1fd6260..845b5e8068d 100644
--- a/src/cli/sync-flags.ts
+++ b/src/cli/sync-flags.ts
@@ -1,16 +1,10 @@
-import { isRouteClientId, type RouteClientId } from "../link/route-intent";
-export type SyncFanoutFlags = { ok: true; localOnly: boolean; json: boolean; overwriteClient?: RouteClientId } | { ok: false; message: string };
+export type SyncFanoutFlags = { ok: true; localOnly: boolean; json: boolean } | { ok: false; message: string };
export function parseSyncFanoutFlags(args: readonly string[]): SyncFanoutFlags {
- for (const flag of ["--local-only", "--json", "--overwrite-conflict"]) {
+ for (const flag of ["--local-only", "--json"]) {
if (args.filter(arg => arg === flag).length > 1 || args.some(arg => arg.startsWith(`${flag}=`))) {
return { ok: false, message: `ocx sync accepts ${flag} once without a value.` };
}
}
- const selectors = args.flatMap((arg, index) => arg === "--overwrite-client" ? [args[index + 1]] : arg.startsWith("--overwrite-client=") ? [arg.slice(19)] : []);
- if (selectors.length > 1 || (selectors.length && (!args.includes("--overwrite-conflict") || !isRouteClientId(selectors[0])))) {
- return { ok: false, message: "--overwrite-client needs one supported client and --overwrite-conflict." };
- }
- return { ok: true, localOnly: args.includes("--local-only"), json: args.includes("--json"),
- ...(selectors.length ? { overwriteClient: selectors[0] as RouteClientId } : {}) };
+ return { ok: true, localOnly: args.includes("--local-only"), json: args.includes("--json") };
}
diff --git a/src/client/connect.ts b/src/client/connect.ts
index 4bf34ec57e2..af6bf711eaf 100644
--- a/src/client/connect.ts
+++ b/src/client/connect.ts
@@ -2,12 +2,10 @@ import { createHash } from "node:crypto";
import {
existsSync,
lstatSync,
- mkdirSync,
readFileSync,
unlinkSync,
} from "node:fs";
import { hostname } from "node:os";
-import { dirname } from "node:path";
import { atomicWriteFile, loadConfig, withConfigMutationLockSync } from "../config";
import { claudeDesktopIntegrationEnabledNow } from "../codex/desired-state";
import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start";
@@ -30,11 +28,9 @@ import {
type CodexRoutingTarget,
} from "../codex/inject";
import {
- JOURNAL_PATH,
journalOwner,
restoreJournalState,
} from "../codex/journal";
-import { readCodexHomeJournal } from "../codex/codex-home-owner";
import { DEFAULT_CATALOG_PATH, getCodexHome } from "../codex/paths";
import {
readServiceApiTokenState,
@@ -100,15 +96,7 @@ import {
import { hubStateCachePath } from "./hub-state";
import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state";
import { isLinkPort } from "../link/ports";
-import { assertRouteIntentPort, clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime";
-import { prepareConnectedRouteIntent, initialConnectRouteIntent, connectedRouteSelectedClients, applyConnectedRouteIntent } from "../integrations/route-intent-connected";
-import { clearRouteIntentReceipt, readRouteIntentReceiptStrict } from "../integrations/route-intent-state";
-import type { RouteIntentReceipt } from "../integrations/route-intent-contract";
-import { clearHomeRouteIntentRecord } from "./link-home";
-import { clearInitialRouteIntent, recordInitialRouteIntent } from "../integrations/route-intent-initial";
-import { cleanupConnectedRouteIntent } from "../integrations/route-intent-disconnect";
-import { nativeIntentOriginalsPath, readNativeIntentOriginals, restoreNativeIntentOriginals } from "./route-intent-native-originals";
-import { clearFileIntentOriginals } from "./route-intent-file-originals";
+import { clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime";
class RotationRecoveryRequiredError extends Error {
constructor(message: string, options?: ErrorOptions) {
@@ -122,8 +110,6 @@ export interface ConnectOptions {
managementUrl?: string;
credential: OneTimeConnectCredential | LinkClientCredential;
selectedClients: OcxConnectedClientId[];
- /** Provisioning selection retained until Home revises that client's device policy. */
- selectedClientsExplicit?: boolean;
managementTransport: "direct" | "relay";
transport?: "hub" | "link";
link?: { tunnelPort: number; linkId: string };
@@ -619,9 +605,7 @@ async function connectClientUnlocked(
let priorCatalog: CatalogSnapshot | null = null;
let writtenCatalogFingerprint: string | null = null;
let injectionCommitted = false;
- let restoreNativeIntent = false;
let committed = false;
- let initialSelectionWritten = false;
// Before any catalog, journal, client-state or credential write: `CODEX_HOME` is shared with the
// live proxy a sibling instance runs beside, and connecting re-points it (`sibling-start.ts`).
if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
@@ -652,7 +636,6 @@ async function connectClientUnlocked(
// Only link mode needs the local port before the hub is contacted; hub mode keeps reading config
// after the catalog download, so its stderr order (catalog refusal first) is unchanged.
const earlyConfig = linkMode ? loadConfig() : undefined;
- if (earlyConfig) assertRouteIntentPort(earlyConfig.port);
if (earlyConfig && (!Number.isInteger(earlyConfig.port) || earlyConfig.port < 1 || earlyConfig.port > 65535)) {
throw new Error("link mode requires a valid local config port");
}
@@ -738,62 +721,37 @@ async function connectClientUnlocked(
assertClientCatalogCompatible(catalog.body, deps.catalogCompatibility);
writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
assertActiveConnectingState(persisted.fingerprint);
- mkdirSync(dirname(DEFAULT_CATALOG_PATH), { recursive: true });
atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
return sha256(catalog.body);
}), deps.lifecycleLockDeps);
const config = earlyConfig ?? loadConfig();
- const routeIntent = initialConnectRouteIntent(linkMode && linkMetadata
- ? await prepareConnectedRouteIntent({ transport: "link", link: linkMetadata, apiKeyId }, admissionToken, deps.linkHome,
- () => assertActiveConnectingState(persisted.fingerprint)) : undefined,
- options.selectedClients, options.selectedClientsExplicit);
- const selectedClients = connectedRouteSelectedClients(routeIntent, options.selectedClients);
const target = routingTarget(serverUrl, linkMode ? config.port : undefined, config);
// A link Child writes the Home's OpenCodex-owned Codex settings, not its own (#6957). A read
// that fails here keeps the local values; the next `ocx sync` applies the Home's.
// Read only: the record is written after the connection commits, so a rollback leaves none.
- const homeSettings = linkMode && linkMetadata && selectedClients.includes("codex")
+ const homeSettings = linkMode && linkMetadata && options.selectedClients.includes("codex")
? await fetchHomeCodexSettings({ transport: "link", link: linkMetadata, apiKeyId }, admissionToken, deps.linkHome)
: undefined;
const injectConfig = {
...(homeSettings?.kind === "fresh" ? overlayHomeCodexSettings(config, homeSettings.document.settings) : config),
syncResumeHistory: false,
};
- const beforeCodexWrite = () => {
- assertActiveConnectingState(persisted.fingerprint);
- if (routeIntent && (!connectedRouteSelectedClients(routeIntent, options.selectedClients).includes("codex")
- || loadConfig().port !== config.port)) throw new Error("Codex route intent or runtime changed; retry connect");
- };
- if (!options.noSync && routeIntent && selectedClients.includes("codex") && existsSync(getCodexHome())) {
- const { persistNativeRouteIntent } = await import("./route-intent-apply");
- // Armed before publication: the write-ahead originals record can exist even when the
- // config write or its bookkeeping throws, and rollback must still put the field back.
- restoreNativeIntent = true;
- withClientLifecycleSync(() => withConfigMutationLockSync(() => {
- beforeCodexWrite();
- // Home intent owns a selected installed client, including a standalone OFF. Keep
- // compensation field-scoped: a failed enrollment restores the recorded standalone
- // value, keeps a later edit by the person, and keeps disjoint config changes.
- persistNativeRouteIntent("codex", true, beforeCodexWrite, injectConfig, true);
- }), deps.lifecycleLockDeps);
- }
- const preflight = !selectedClients.includes("codex") || (routeIntent && !existsSync(getCodexHome()))
- ? { success: true, message: "Codex is not selected or installed" } : await injectCodexConfig(config.port, injectConfig, {
+ const preflight = await injectCodexConfig(config.port, injectConfig, {
validateOnly: true,
routingTarget: target,
catalogPath: DEFAULT_CATALOG_PATH,
journalOwner: { kind: "client", apiKeyId },
- beforeClientWrite: beforeCodexWrite,
+ beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint),
});
if (!preflight.success) throw new Error(preflight.message);
- if (!options.noSync && selectedClients.includes("codex") && (!routeIntent || existsSync(getCodexHome()))) {
+ if (!options.noSync && options.selectedClients.includes("codex")) {
const injected = await injectCodexConfig(config.port, injectConfig, {
routingTarget: target,
catalogPath: DEFAULT_CATALOG_PATH,
journalOwner: { kind: "client", apiKeyId },
- beforeClientWrite: beforeCodexWrite,
+ beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint),
});
if (!injected.success || injected.status === "skipped") throw new Error(injected.message);
injectionCommitted = true;
@@ -805,7 +763,7 @@ async function connectClientUnlocked(
serverUrl,
managementUrl,
managementTransport: options.managementTransport,
- selectedClients: [...selectedClients],
+ selectedClients: [...options.selectedClients],
tokenEnv: "OPENCODEX_API_AUTH_TOKEN",
apiKeyId,
tokenFingerprint: persisted.fingerprint,
@@ -821,10 +779,6 @@ async function connectClientUnlocked(
};
withClientLifecycleSync(() => withConfigMutationLockSync(() => {
assertActiveConnectingState(persisted.fingerprint);
- if (linkMetadata && options.selectedClientsExplicit) {
- recordInitialRouteIntent(linkMetadata.linkId, routeIntent?.document, options.selectedClients);
- initialSelectionWritten = true;
- }
clearClientConnectPending(persisted.fingerprint);
commitClientConnection(connection);
committed = true;
@@ -841,21 +795,6 @@ async function connectClientUnlocked(
console.warn("Warning: the Home's Codex settings were applied, but the apply record was not written; run 'ocx sync' to record it.");
}
}
- if (!options.noSync && routeIntent) {
- const appliedConnection = withClientLifecycleSync(() => {
- const current = readClientConnectionState();
- if (current.kind !== "connected" || !Bun.deepEquals(current.value, connection, true)) throw new Error("client_connection_changed");
- return current.value;
- }, deps.lifecycleLockDeps);
- try { await applyConnectedRouteIntent(connection, admissionToken, routeIntent, {
- codexApplied: injectionCommitted, lifecycleLockDeps: deps.lifecycleLockDeps, beforeWrite: () => {
- deps.signal?.throwIfAborted(); assertClientConnectionUnchanged(appliedConnection);
- const token = readServiceApiTokenState();
- if (token.kind !== "present" || token.fingerprint !== connection.tokenFingerprint) throw new Error("client_token_changed");
- },
- }, deps.linkHome); }
- catch { console.warn("Warning: local route intent needs reconciliation; run 'ocx route to sync'."); }
- }
return connection;
} catch (error) {
const rollbackFailures: string[] = [];
@@ -866,25 +805,10 @@ async function connectClientUnlocked(
try {
withClientLifecycleSync(() => withConfigMutationLockSync(() => {
assertNoClientDisconnectPending();
- if (initialSelectionWritten && !committed && linkMetadata) {
- try { clearInitialRouteIntent(linkMetadata.linkId); }
- catch { rollbackFailures.push("initial route selection rollback unavailable"); }
- }
- let nativeRestoreFailed = false;
- if (restoreNativeIntent && !committed && pendingConnectFingerprint) {
- const fingerprint = pendingConnectFingerprint;
- try { restoreNativeIntentOriginals(() => assertConnectingState(fingerprint)); }
- catch {
- // Like disconnect, keep the token (and the marker proving it is this attempt's) until the
- // standalone switches are back; ocx disconnect finishes this rollback.
- nativeRestoreFailed = true;
- rollbackFailures.push(`Standalone native switches were not restored, so the enrollment token was kept. Repair the config or ${nativeIntentOriginalsPath()}, then run ocx disconnect to finish the rollback.`);
- }
- }
if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) {
rollbackFailures.push("catalog rollback did not match the written artifact");
}
- if (pendingConnectFingerprint && !nativeRestoreFailed) {
+ if (pendingConnectFingerprint) {
const removed = removeServiceApiTokenFileIfOwned(pendingConnectFingerprint);
if (removed === "changed") rollbackFailures.push("service token changed during rollback");
// Final commit may fail after this attempt already cleared its marker under the same lock.
@@ -915,7 +839,6 @@ async function connectClientUnlocked(
}
export interface ConnectedSyncResult {
- routeIntent?: RouteIntentReceipt;
catalogWritten: boolean;
cacheSynced: boolean;
injected: boolean;
@@ -935,8 +858,6 @@ export interface ConnectedSyncResult {
export interface ConnectedSyncOptions {
restartCodex?: boolean;
- overwriteConflict?: boolean;
- overwriteClient?: import("../link/route-intent").RouteClientId;
/**
* #7694: refuse unless this is the connection found under the lock. A mismatch throws
* `ClientSyncExpectationError` before any request or write.
@@ -1009,21 +930,17 @@ async function syncConnectedClientUnlocked(
// with one it cannot. Refusing leaves the connection and the existing catalog exactly as
// they were, which is the known-good state.
if (downloaded) assertClientCatalogCompatible(downloaded.body, deps.catalogCompatibility);
- const routeIntent = await prepareConnectedRouteIntent(initial.connection, initial.token.token, deps.linkHome, () => {
- initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(initial.connection);
- });
const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
initial.assertRuntimeCurrent();
assertClientConnectionUnchanged(initial.connection);
const token = readServiceApiTokenState();
if (token.kind !== "present" || token.fingerprint !== initial.token.fingerprint) throw new Error("client_token_changed");
- if (!downloaded) validLocalCatalog();
- else atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body);
+ if (!downloaded) { validLocalCatalog(); return initial.connection; }
+ atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body);
const updated = {
...initial.connection,
- selectedClients: [...connectedRouteSelectedClients(routeIntent, initial.connection.selectedClients)],
- ...(downloaded ? { catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"),
- catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString() } : {}),
+ catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"),
+ catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString(),
};
commitClientConnection(updated);
return updated;
@@ -1031,7 +948,6 @@ async function syncConnectedClientUnlocked(
// Read-only: injection invokes this while N/C are held. Acquiring L here would invert C→L.
const beforeClientWrite = () => {
initial.assertRuntimeCurrent();
- if (isLinkConnection(next)) assertRouteIntentPort(loadConfig().port);
assertClientConnectionUnchanged(next);
const token = readServiceApiTokenState();
if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed");
@@ -1041,13 +957,7 @@ async function syncConnectedClientUnlocked(
let homeSettings: HomeCodexSettingsSource | undefined;
let appliedRecord: HomeCodexSettingsRecord | undefined;
let applyRecordFailed = false;
- if (next.selectedClients.includes("codex") && (!routeIntent || existsSync(getCodexHome()))) {
- const beforeCodexWrite = () => {
- beforeClientWrite();
- if (routeIntent && !connectedRouteSelectedClients(routeIntent, next.selectedClients).includes("codex")) {
- throw new Error("Codex route intent changed; retry sync");
- }
- };
+ if (next.selectedClients.includes("codex")) {
let config = loadConfig();
// #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog.
if (isLinkConnection(next)) {
@@ -1061,15 +971,9 @@ async function syncConnectedClientUnlocked(
withClientWrite(() => withdrawChangingHomeCodexSettingsRecord(linkId, document));
}
}
- // Off persisted false. Reconcile the effective Home intent before the injector rereads
- // that durable gate, retaining local opt-outs and the owner/runtime/token commit fences.
- if (routeIntent) {
- const { persistNativeRouteIntent } = await import("./route-intent-apply");
- withClientLifecycleSync(() => persistNativeRouteIntent("codex", true, beforeCodexWrite, config, true), deps.lifecycleLockDeps);
- }
const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH,
- journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite: beforeCodexWrite,
+ journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite,
});
if (!result.success || result.status === "skipped") throw new Error(result.message);
injected = true;
@@ -1088,9 +992,6 @@ async function syncConnectedClientUnlocked(
}
}
}
- const routeReceipt = await applyConnectedRouteIntent(next, initial.token.token, routeIntent,
- { beforeWrite: beforeClientWrite, codexApplied: injected, lifecycleLockDeps: deps.lifecycleLockDeps,
- overwriteConflict: options.overwriteConflict, overwriteClient: options.overwriteClient }, deps.linkHome);
const cacheSynced = withClientLifecycleSync(() => {
// Acquire K before the guarded C commit; never acquire K from inside C.
const owningHome = getCodexHome();
@@ -1100,11 +1001,10 @@ async function syncConnectedClientUnlocked(
return result.kind === "completed" && result.value;
}, deps.lifecycleLockDeps);
return {
- catalogWritten: downloaded !== undefined, cacheSynced, injected, stale: stale || routeReceipt?.catalogPending === true || routeIntent?.stale === true,
+ catalogWritten: downloaded !== undefined, cacheSynced, injected, stale,
...(homeSettings ? { homeSettings } : {}),
...(appliedRecord ? { appliedRecord } : {}),
...(applyRecordFailed ? { applyRecordFailed } : {}),
- ...(routeReceipt ? { routeIntent: routeReceipt } : {}),
connection: next,
};
}
@@ -1169,101 +1069,28 @@ function verifyDisconnectCatalog(receipt: DesktopDisconnectReceipt): void {
}
}
-function restoreConnectedCodex(connection: OcxClientConnectionConfig, receipt: DesktopDisconnectReceipt, recoverConfig = false): string | undefined {
- const inspection = readCodexHomeJournal(JOURNAL_PATH);
- const owner = journalOwner({ readOnly: true });
- const explicitOwner = inspection.kind === "read" ? inspection.journal.owner as Record | undefined : undefined;
- const invalidOwner = explicitOwner !== undefined && (!explicitOwner || typeof explicitOwner !== "object" || Array.isArray(explicitOwner)
- || !(explicitOwner.kind === "client" && typeof explicitOwner.apiKeyId === "string" && explicitOwner.apiKeyId.length > 0
- || explicitOwner.kind === "process" && typeof explicitOwner.pid === "number" && Number.isSafeInteger(explicitOwner.pid) && explicitOwner.pid > 0));
- if (inspection.kind === "unknown" || invalidOwner || (inspection.kind === "read" && owner === null)) {
- throw new Error("disconnect refused: Codex journal ownership is unknown; journal and connection credentials were retained");
- }
- // Deselection records desired state, not completed cleanup. A failed off still leaves
- // this client's journal to drain; an unrelated process journal grants no such authority.
- if (!connection.selectedClients.includes("codex") && owner?.kind !== "client") {
- if (inspection.kind === "missing" && isCodexRoutingInjected()) {
- throw new Error("disconnect refused: Codex routing is injected but no journal records the original state");
- }
- return;
- }
+function restoreConnectedCodex(connection: OcxClientConnectionConfig): void {
+ if (!connection.selectedClients.includes("codex")) return;
+ const owner = journalOwner();
if (owner && owner.kind === "client" && owner.apiKeyId !== connection.apiKeyId) {
throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key");
}
if (owner !== null) {
- const restored = restoreJournalState({ preserveConfigEdits: true,
- ...(recoverConfig ? { recoverConfigForClient: connection.apiKeyId } : {}),
- beforeWrite: () => {
- const state = readClientConnectionState(), currentReceipt = readDesktopDisconnectReceipt();
- const token = readServiceApiTokenState();
- if (state.kind !== "connected" || JSON.stringify(state.value) !== JSON.stringify(connection)
- || currentReceipt.kind !== "valid" || JSON.stringify(currentReceipt.value) !== JSON.stringify(receipt)
- || token.kind !== "present" || token.fingerprint !== receipt.tokenFingerprint) throw new Error("client_disconnect_owner_changed");
- },
- });
- if (!restored.complete) throw new Error(
- "disconnect refused: Codex journal restore was partial; journal and connection credentials were retained. "
- + "Retry 'ocx disconnect --recover-config --yes' to privately back up current Codex config/profile and restore only evidenced OCX-owned fields (retain --keep-catalog if originally requested). "
- + "Unknown ownership and edited or unverified profiles remain refused.",
- );
- return restored.recoveryBackupDir;
+ if (!restoreJournalState().complete) throw new Error("disconnect refused: Codex journal restore was partial");
} else if (isCodexRoutingInjected()) {
throw new Error("disconnect refused: Codex routing is injected but no journal records the original state");
}
}
-/**
- * A disconnected machine with no unfinished disconnect can still hold the standalone native
- * switches an interrupted or failed enrollment displaced, and the token that enrollment kept
- * because it could not restore them. Plain `ocx disconnect` restores the switches, then removes
- * that token only while the pending-connect marker proves it is the enrollment's own.
- * `ocx disconnect --orphaned-token` never touches the record.
- */
-function drainLoneNativeIntentOriginals(lifecycleLockDeps?: ClientLifecycleLockDeps): {
- restored: true; tokenRemoved: boolean; catalogRemoved: false; catalogRestored: false; apiKeyId: string; restartRequired: false; nativeIntentOnly: true;
-} | null {
- const refused = (error: unknown) => new Error(`disconnect refused: the standalone native switches recorded in ${nativeIntentOriginalsPath()} were not restored (${error instanceof Error ? error.message : String(error)}). Repair the config or that record (remove it only after setting the switches by hand), then rerun ocx disconnect.`);
- return withClientLifecycleSync(() => withConfigMutationLockSync(() => {
- const read = readDesktopDisconnectReceipt();
- if (read.kind === "unsafe" || (read.kind === "valid" && read.value.phase !== "complete")) return null;
- if (readClientConnectionState().kind !== "disconnected") return null;
- let present: boolean;
- try { present = readNativeIntentOriginals() !== null; } catch (error) { throw refused(error); }
- if (!present) return null;
- try {
- restoreNativeIntentOriginals(() => {
- assertNoClientDisconnectPending();
- if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed");
- });
- } catch (error) { throw refused(error); }
- const token = readServiceApiTokenState();
- let tokenRemoved = false;
- if (token.kind === "present" && pendingClientConnectMayOwnToken(token.fingerprint)) {
- const removed = removeServiceApiTokenFileIfOwned(token.fingerprint);
- if (removed === "changed") throw new Error("client_token_changed");
- tokenRemoved = removed === "removed";
- clearClientConnectPending(token.fingerprint);
- }
- return { restored: true, tokenRemoved, catalogRemoved: false, catalogRestored: false, apiKeyId: "", restartRequired: false, nativeIntentOnly: true };
- }), lifecycleLockDeps);
-}
-
export async function disconnectClient(
- options: { keepCatalog?: boolean; expectedOwner?: DesktopRemoteOwner; recoverConfig?: boolean } = {},
+ options: { keepCatalog?: boolean; expectedOwner?: DesktopRemoteOwner } = {},
deps: Pick = {},
): Promise<{
restored: boolean; tokenRemoved: boolean; catalogRemoved: boolean; catalogRestored: boolean; apiKeyId: string;
desktopRestoration?: "owned_projection" | "standard_fallback" | "selection_preserved";
restartRequired: boolean;
- recoveryBackupDir?: string;
- /** Only standalone native switches an interrupted enrollment displaced were restored. */
- nativeIntentOnly?: true;
}> {
if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
- if (!options.expectedOwner) {
- const drained = drainLoneNativeIntentOriginals(deps.lifecycleLockDeps);
- if (drained) return drained;
- }
const keepCatalog = options.keepCatalog === true;
const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => {
const read = readDesktopDisconnectReceipt();
@@ -1303,14 +1130,11 @@ export async function disconnectClient(
return { receipt, connection };
}), deps.lifecycleLockDeps);
- // This sibling step owns only file integrations; failures retain the prepared receipt/token.
- if (prepared.connection?.transport === "link" && !disconnectAtLeast(prepared.receipt, "desktop_restored")) {
- await cleanupConnectedRouteIntent(prepared.connection, prepared.receipt, deps.lifecycleLockDeps, options.recoverConfig === true);
- }
// The prepared receipt blocks even a sync queued for its actual N/C injection commit.
// Codex-only restore MUST stay outside L; it never invokes Desktop cleanup.
- const recoveryBackupDir = prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored")
- ? restoreConnectedCodex(prepared.connection, prepared.receipt, options.recoverConfig === true) : undefined;
+ if (prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored")) {
+ restoreConnectedCodex(prepared.connection);
+ }
return withClientLifecycleSync(held => withConfigMutationLockSync(() => {
const read = readDesktopDisconnectReceipt();
@@ -1361,17 +1185,6 @@ export async function disconnectClient(
}
advance("catalog_settled", { catalogAfter: catalogAfterState() });
} else verifyDisconnectCatalog(receipt);
- if (connection?.transport === "link" && !disconnectAtLeast(receipt, "removing_token")) {
- if (connection.link) clearInitialRouteIntent(connection.link.linkId);
- clearHomeRouteIntentRecord();
- // Its clients were restored by the route cleanup above, which phase gating never reruns.
- clearFileIntentOriginals();
- if (readRouteIntentReceiptStrict()?.linkId === connection.link?.linkId) clearRouteIntentReceipt();
- }
- // Standalone native switches come back before the token that displaced them is removed.
- // The record is machine-scoped, so any transport drains one a crashed enrollment left.
- // The locks and the receipt checked above already fence this; a retry is idempotent.
- if (!disconnectAtLeast(receipt, "removing_token")) restoreNativeIntentOriginals(() => {});
if (!disconnectAtLeast(receipt, "removing_token")) advance("removing_token");
const tokenRemoval = removeServiceApiTokenFileIfOwned(receipt.tokenFingerprint);
if (tokenRemoval === "changed") throw new Error("client_token_changed");
@@ -1395,7 +1208,6 @@ export async function disconnectClient(
requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner));
if (receipt.phase !== "complete") advance("complete");
return {
- ...(recoveryBackupDir ? { recoveryBackupDir } : {}),
restored: true, tokenRemoved: tokenRemoval === "removed",
catalogRemoved: !keepCatalog, catalogRestored: !keepCatalog && receipt.catalogAfter?.kind === "file",
apiKeyId: receipt.owner.apiKeyId, restartRequired: desktop.restartRequired,
diff --git a/src/client/link-home.ts b/src/client/link-home.ts
index fede84eb9d7..3452e602ee1 100644
--- a/src/client/link-home.ts
+++ b/src/client/link-home.ts
@@ -7,8 +7,7 @@
* process that took the tunnel port never sees the key.
*/
import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
-import { dirname, join } from "node:path";
-import { parseHomeRouteIntentDocument, parseRouteClientOutcomes, ROUTE_CLIENT_IDS, routeIntentDigest, routeClientOutcomesForPeer, type HomeRouteIntentDocument, type RouteClientOutcome } from "../link/route-intent";
+import { join } from "node:path";
import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
import { readBoundedResponseBytes } from "../lib/bounded-body";
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
@@ -21,7 +20,6 @@ import {
import { LINK_CHILD_PARITY_PATH, type LinkChildParityReport } from "../link/child-parity";
import {
LINK_CODEX_SETTINGS_PATH,
- LINK_ROUTE_INTENT_PATH,
LINK_MANAGEMENT_PREFIX,
linkManagementReadAllowed,
} from "../link/management-routes";
@@ -338,22 +336,19 @@ export function recordHomeCodexSettings(
/**
* Tell the Home what this Child last applied and when its runtime started (OCX-6). Resolves to
- * `"accepted"`, `"unsupported"` for a Home that predates the route, `"schema-rejected"`
- * for a payload/schema refusal, or `"failed"` for every other refusal/transport failure.
+ * `"accepted"`, `"unsupported"` for a Home that predates the route, or `"failed"`.
*/
export async function sendLinkChildParityReport(
connection: LinkHomeConnection,
admissionKey: string,
report: LinkChildParityReport,
deps: LinkHomeDeps = {},
-): Promise<"accepted" | "unsupported" | "schema-rejected" | "failed"> {
+): Promise<"accepted" | "unsupported" | "failed"> {
let response: Response;
try {
const target = linkTarget(connection, admissionKey);
const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_CHILD_PARITY_PATH}`;
- const body = JSON.stringify({ ...report, ...(report.routeClientOutcomes === undefined ? {} : {
- routeClientOutcomes: routeClientOutcomesForPeer(report.routeClientOutcomes),
- }) });
+ const body = JSON.stringify(report);
const request: RequestInit = {
method: "POST",
headers: {
@@ -372,88 +367,5 @@ export async function sendLinkChildParityReport(
}
try { await response.body?.cancel(); } catch { /* best effort */ }
if (response.status === 404) return "unsupported";
- if ([400, 413, 422].includes(response.status)) return "schema-rejected";
return response.status === 204 ? "accepted" : "failed";
}
-
-export interface HomeRouteIntentRecord {
- linkId: string;
- fetchedAt: string;
- appliedAt: string;
- document: HomeRouteIntentDocument;
- appliedRouteIntentDigest: string | null;
- routeClientOutcomes: RouteClientOutcome[];
-}
-export function homeRouteIntentPath(): string { return join(linkDir(), "home-route-intent.json"); }
-function completedRouteIntentDigest(document: HomeRouteIntentDocument, outcomes: RouteClientOutcome[]): string | null {
- return outcomes.length === ROUTE_CLIENT_IDS.length && new Set(outcomes.map(row => row.client)).size === ROUTE_CLIENT_IDS.length
- && outcomes.every(row => row.outcome === "current" && !(row.source === "local" && document.clients[row.client].enabled))
- ? routeIntentDigest(document) : null;
-}
-function parseHomeRouteIntentRecord(value: unknown, linkId: string): HomeRouteIntentRecord | null {
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
- const raw = value as Record;
- const fields = ["linkId", "fetchedAt", "appliedAt", "document", "appliedRouteIntentDigest", "routeClientOutcomes"];
- if (Object.keys(raw).length !== fields.length || Object.keys(raw).some(key => !fields.includes(key))
- || raw.linkId !== linkId || !/^lnk_[0-9a-f]{16}$/.test(linkId) || !isIsoTimestamp(raw.fetchedAt) || !isIsoTimestamp(raw.appliedAt)) return null;
- const document = parseHomeRouteIntentDocument(raw.document);
- const outcomes = parseRouteClientOutcomes(raw.routeClientOutcomes);
- if (!document || !outcomes || raw.appliedRouteIntentDigest !== completedRouteIntentDigest(document, outcomes)) return null;
- return { linkId, fetchedAt: raw.fetchedAt, appliedAt: raw.appliedAt, document,
- appliedRouteIntentDigest: raw.appliedRouteIntentDigest as string | null, routeClientOutcomes: outcomes };
-}
-export function readHomeRouteIntentRecord(linkId: string, path = homeRouteIntentPath()): HomeRouteIntentRecord | null {
- try {
- const stat = lstatSync(path);
- if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return null;
- return parseHomeRouteIntentRecord(JSON.parse(readFileSync(path, "utf8")), linkId);
- } catch { return null; }
-}
-export function writeHomeRouteIntentRecord(record: HomeRouteIntentRecord, path = homeRouteIntentPath()): void {
- const parsed = parseHomeRouteIntentRecord(record, record.linkId);
- if (!parsed) throw new Error("invalid Home route intent receipt");
- const dir = dirname(path);
- assertNotRealHomeUnderTest(dir);
- const body = `${JSON.stringify(parsed, null, 2)}\n`;
- if (Buffer.byteLength(body) > RECORD_MAX_BYTES) throw new Error("Home route intent receipt too large");
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- if (process.platform === "win32") hardenSecretDir(dir, { required: true });
- else chmodSync(dir, 0o700);
- atomicWriteFile(path, body);
- if (process.platform !== "win32") chmodSync(path, 0o600);
-}
-export function clearHomeRouteIntentRecord(path = homeRouteIntentPath()): void {
- assertNotRealHomeUnderTest(dirname(path));
- try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
-}
-/** Call only after the materializer has collected the outcomes; partial apply never claims the digest. */
-export function homeRouteIntentReceipt(
- linkId: string, document: HomeRouteIntentDocument, fetchedAt: string, appliedAt: string, routeClientOutcomes: RouteClientOutcome[],
- previous: HomeRouteIntentRecord | null = readHomeRouteIntentRecord(linkId),
-): HomeRouteIntentRecord {
- const digest = completedRouteIntentDigest(document, routeClientOutcomes);
- const unchanged = digest !== null && previous?.linkId === linkId && previous.appliedRouteIntentDigest === digest;
- return { linkId, document, fetchedAt, appliedAt: unchanged ? previous.appliedAt : appliedAt,
- appliedRouteIntentDigest: digest, routeClientOutcomes };
-}
-export type HomeRouteIntentFetch = { kind: "fresh"; document: HomeRouteIntentDocument } | { kind: "unsupported" } | { kind: "failed"; reason: string };
-export async function fetchHomeRouteIntent(connection: LinkHomeConnection, admissionKey: string, deps: LinkHomeDeps = {}): Promise {
- let response: Response;
- try {
- const target = linkTarget(connection, admissionKey);
- const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_ROUTE_INTENT_PATH}`;
- const request: RequestInit = { method: "GET", headers: { accept: "application/json", authorization: `Bearer ${admissionKey}` },
- redirect: "manual", signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS) };
- response = deps.fetchImpl ? await deps.fetchImpl(destination, request) : await fetchBoundLinkRelay(target, destination, request);
- } catch { return { kind: "failed", reason: "the Home route intent is unreachable" }; }
- if (!response.ok) {
- try { await response.body?.cancel(); } catch { /* best effort */ }
- return response.status === 404 ? { kind: "unsupported" } : { kind: "failed", reason: `the Home answered HTTP ${response.status}` };
- }
- try {
- const read = await readBoundedResponseBytes(response, { maxBytes: SETTINGS_MAX_BYTES, signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS) });
- if (read.oversized) return { kind: "failed", reason: "the Home route intent exceeded the allowed size" };
- const document = parseHomeRouteIntentDocument(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(read.bytes)));
- return document ? { kind: "fresh", document } : { kind: "failed", reason: "the Home returned an unreadable route intent document" };
- } catch { return { kind: "failed", reason: "the Home returned an unreadable route intent document" }; }
-}
diff --git a/src/client/link-join.ts b/src/client/link-join.ts
index f70425d965f..83c6163fc7a 100644
--- a/src/client/link-join.ts
+++ b/src/client/link-join.ts
@@ -316,21 +316,14 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
const thisAlias = localAlias(deps);
let result: SshRunResult;
try {
- const issue = (selectedClients?: OcxConnectedClientId[]) => deps.runner.run(
+ result = await deps.runner.run(
buildExecArgv({
alias: input.alias,
- argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), ...(selectedClients ? ["--clients", selectedClients.join(",")] : []), "--json"]),
+ argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"]),
knownHostsFile: deps.knownHostsFile,
}),
{ timeoutMs: JOIN_REVOKE_TIMEOUT_MS },
);
- result = await issue(deps.selectedClients);
- // The older CLI rejects unknown arguments before issuing a key. Retry only that
- // definite no-write result; keep the selection for the Child's existing fallback.
- if (deps.selectedClients && result.code === 2 && !result.stdout.trim()
- && /^(?:Error: )?Unexpected argument\(s\): --clients(?:\s|$)/m.test(result.stderr)) {
- result = await issue();
- }
} catch (error) {
throw new ClientLinkJoinError("join_issue_failed", undefined, sshRunnerErrorHint(error));
}
@@ -390,7 +383,6 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
transport: "link",
link: { tunnelPort, linkId: issued.linkId },
selectedClients: deps.selectedClients ?? ["codex", "claude"],
- selectedClientsExplicit: deps.selectedClients !== undefined,
managementTransport: "direct",
}, {
fetchImpl: deps.fetchImpl,
diff --git a/src/client/link-parity-reporter.ts b/src/client/link-parity-reporter.ts
index 7fa14024e88..ff4d4e1f437 100644
--- a/src/client/link-parity-reporter.ts
+++ b/src/client/link-parity-reporter.ts
@@ -8,12 +8,9 @@
* predates the route (404) is not asked again. Timers are unref'd and never keep the process alive.
*/
import { codexSettingsDigest } from "../link/codex-settings";
-import { routeClientOutcomesForPeer, routeIntentDigest, type RouteClientId } from "../link/route-intent";
-import type { RouteIntentReceipt } from "../integrations/route-intent-contract";
-import { effectiveRouteIntent, readLocalRouteIntentOff, readRouteIntentReceiptStrict } from "../integrations/route-intent-state";
import type { LinkChildParityReport } from "../link/child-parity";
import type { OcxClientConnectionConfig } from "../types";
-import { readHomeCodexSettingsRecord, readHomeRouteIntentRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord, type HomeRouteIntentRecord } from "./link-home";
+import { readHomeCodexSettingsRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord } from "./link-home";
export const LINK_PARITY_RETRY_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const;
export const LINK_PARITY_PERIOD_MS = 60_000;
@@ -28,7 +25,6 @@ function nextReportSequence(): number {
export interface LinkParityReporter {
start(): void;
stop(): void;
- requestReport(): void;
}
export interface LinkParityReporterDeps {
@@ -37,9 +33,6 @@ export interface LinkParityReporterDeps {
/** ISO start time of this runtime. */
runtimeStartedAt: string;
readRecord?: (linkId: string) => HomeCodexSettingsRecord | null;
- readRouteRecord?: (linkId: string) => HomeRouteIntentRecord | null;
- readCommonReceipt?: () => RouteIntentReceipt | null;
- readLocalOff?: () => RouteClientId[];
send?: typeof sendLinkChildParityReport;
setTimer?: (callback: () => void, ms: number) => unknown;
clearTimer?: (timer: unknown) => void;
@@ -51,24 +44,18 @@ export function linkChildParityReport(
runtimeStartedAt: string,
record: HomeCodexSettingsRecord | null,
sequence: number = nextReportSequence(),
- routeRecord?: HomeRouteIntentRecord | null,
): LinkChildParityReport | null {
const linkId = connection.link?.linkId;
const started = Date.parse(runtimeStartedAt);
if (!linkId || !Number.isFinite(started)) return null;
const applied = record ? Date.parse(record.appliedAt) : Number.NaN;
- const report: LinkChildParityReport = Number.isFinite(applied)
+ return Number.isFinite(applied)
? { linkId, lastApplyAt: applied, runtimeStartedAt: started, appliedCodexSettingsDigest: codexSettingsDigest(record!.document.settings), sequence }
: { linkId, lastApplyAt: null, runtimeStartedAt: started, appliedCodexSettingsDigest: null, sequence };
- return routeRecord?.linkId === linkId ? { ...report, appliedRouteIntentDigest: routeRecord.appliedRouteIntentDigest,
- routeClientOutcomes: routeClientOutcomesForPeer(routeRecord.routeClientOutcomes) } : report;
}
export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkParityReporter {
const readRecord = deps.readRecord ?? readHomeCodexSettingsRecord;
- const readRouteRecord = deps.readRouteRecord ?? readHomeRouteIntentRecord;
- const readCommonReceipt = deps.readCommonReceipt ?? readRouteIntentReceiptStrict;
- const readLocalOff = deps.readLocalOff ?? readLocalRouteIntentOff;
const send = deps.send ?? sendLinkChildParityReport;
const setTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
const timer = setTimeout(callback, ms);
@@ -79,8 +66,6 @@ export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkPari
let timer: unknown;
let stopped = true;
let failures = 0;
- let inFlight = false;
- let rerun = false;
const schedule = (ms: number) => {
if (stopped) return;
@@ -90,51 +75,20 @@ export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkPari
const tick = async (): Promise => {
timer = undefined;
if (stopped) return;
- if (inFlight) { rerun = true; return; }
- inFlight = true;
- let delay: number = LINK_PARITY_PERIOD_MS;
- try {
const key = deps.linkKey();
const linkId = deps.connection.link?.linkId;
let record: HomeCodexSettingsRecord | null = null;
try { record = linkId ? readRecord(linkId) : null; } catch { record = null; }
- let routeRecord: HomeRouteIntentRecord | null = null;
- try {
- routeRecord = linkId ? readRouteRecord(linkId) : null;
- const common = readCommonReceipt();
- const effective = common ? effectiveRouteIntent(common.document, undefined, readLocalOff()) : null;
- if (!routeRecord || routeRecord.linkId !== linkId || !common || common.linkId !== linkId || common.catalogPending
- || routeIntentDigest(common.document) !== routeIntentDigest(routeRecord.document)
- || common.appliedRouteIntentDigest !== routeRecord.appliedRouteIntentDigest
- || !Bun.deepEquals(common.clients, effective?.clients, true) || !Bun.deepEquals(common.sources, effective?.sources, true)
- || !Bun.deepEquals(routeClientOutcomesForPeer(common.outcomes), routeClientOutcomesForPeer(routeRecord.routeClientOutcomes), true)) {
- routeRecord = null;
- }
- } catch { routeRecord = null; }
- const base = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record, nextReportSequence(), routeRecord);
- // Omission is not a withdrawal for peers retaining their last route confirmation.
- const report = base && !routeRecord ? { ...base, appliedRouteIntentDigest: null, routeClientOutcomes: [] } : base;
- let outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed";
- if (key && report && outcome === "schema-rejected" && report.routeClientOutcomes !== undefined) {
- const { appliedRouteIntentDigest: _digest, routeClientOutcomes: _outcomes, ...baseReport } = report;
- outcome = await send(deps.connection, key, baseReport).catch(() => "failed" as const);
- }
+ const report = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record);
+ const outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed";
if (outcome === "unsupported") { stopped = true; return; }
if (outcome === "accepted") {
failures = 0;
+ schedule(LINK_PARITY_PERIOD_MS);
return;
}
failures = Math.min(failures + 1, LINK_PARITY_RETRY_MS.length - 1);
- delay = LINK_PARITY_RETRY_MS[failures]!;
- } catch {
- failures = Math.min(failures + 1, LINK_PARITY_RETRY_MS.length - 1);
- delay = LINK_PARITY_RETRY_MS[failures]!;
- } finally {
- inFlight = false;
- const immediate = rerun;
- rerun = false;
- schedule(immediate ? 0 : delay);
- }
+ schedule(LINK_PARITY_RETRY_MS[failures]!);
};
return {
@@ -148,14 +102,6 @@ export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkPari
stopped = true;
if (timer !== undefined) clearTimer(timer);
timer = undefined;
- rerun = false;
- },
- requestReport() {
- if (stopped) return;
- if (timer !== undefined) clearTimer(timer);
- timer = undefined;
- if (inFlight) rerun = true;
- else void tick();
},
};
}
diff --git a/src/client/route-intent-apply.ts b/src/client/route-intent-apply.ts
deleted file mode 100644
index 51338048cae..00000000000
--- a/src/client/route-intent-apply.ts
+++ /dev/null
@@ -1,293 +0,0 @@
-/** Production native adapters for the common Home/Child local intent controller. */
-import { existsSync } from "node:fs";
-import { homedir } from "node:os";
-import { join } from "node:path";
-import type { ExportModel } from "../clients/config-export";
-import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config";
-import { readConfigForAdmittedMutation } from "../config/persisted-mutation";
-import { commitClaudeCodeBlock } from "../claude/claude-code-block";
-import { localInferenceDestination } from "../lib/local-destinations";
-import { persistCommittedDesktopGateway } from "../claude/desktop-gateway-state";
-import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../claude/desktop-applied-marker";
-import { localClientSyncAllowed } from "../codex/desired-state";
-import { materializeRouteIntent, type RouteClientOutcome, type RouteIntentApplyInput, type RouteIntentReceipt } from "../integrations/route-intent";
-import type { HomeRouteIntentDocument, RouteClientId, RouteClientIntent } from "../link/route-intent";
-import type { OcxConfig } from "../types";
-import { assertNoClientDisconnectPending, readClientConnectionState, sameClientConnectionOwner } from "./state";
-import { withClientLifecycle, withClientLifecycleSync, type ClientLifecycleLockDeps } from "./lifecycle-lock";
-import { assertRouteIntentPort } from "./sync-runtime";
-import { resolveApiSurfaceSettings } from "../protocols/settings";
-import { nativeIntentFields, prepareNativeIntentWrite, publishNativeIntentWrite } from "./route-intent-native-originals";
-
-export interface LocalRouteIntentOptions extends Pick {
- /** Connect/sync already applied the Home's Codex settings through the existing injector. */
- codexApplied?: boolean;
- lifecycleLockDeps?: ClientLifecycleLockDeps;
- /** The native client a Home write is deciding; only it may change a Home native switch. */
- decidedClient?: RouteClientId;
-}
-
-/**
- * The Home's own native switch, read rather than decided by this apply (a sync, or a write for
- * another client). It is never rewritten here; only the client's owned artifacts follow it.
- */
-function liveHomeSwitch(document: HomeRouteIntentDocument, client: RouteClientId, options: LocalRouteIntentOptions): boolean {
- return document.sources[client] === "native" && options.decidedClient !== client && readClientConnectionState().kind !== "connected";
-}
-
-/**
- * An enabled Home Grok or Desktop switch nobody is deciding right now gets exactly the startup
- * behavior: Grok follows the startup gate and Desktop only refreshes a gateway profile OpenCodex
- * already owns. The outcome is "current": the switch is satisfied without creating anything.
- */
-function implicitHomeIntent(document: HomeRouteIntentDocument, client: RouteClientId, intent: RouteClientIntent, options: LocalRouteIntentOptions): boolean {
- return intent.enabled && liveHomeSwitch(document, client, options);
-}
-const IMPLICIT_DESKTOP_MESSAGE = "No OpenCodex-owned Claude Desktop profile to refresh; choose ocx route to claude-desktop on to create one";
-
-/**
- * First-party Desktop already routes through OpenCodex, so an enabled intent is satisfied without
- * a write. Turning it off changes the Desktop mode, which only its own transaction may do.
- */
-function firstPartyDesktopOutcome(enabled: boolean, target?: string): RouteClientOutcome {
- const base = { client: "claude-desktop" as const, ...(target === undefined ? {} : { target }) };
- if (enabled) return { ...base, outcome: "current", message: "Claude Desktop first-party mode already routes through OpenCodex" };
- const command = readClientConnectionState().kind === "connected"
- ? "switch modes with ocx claude desktop apply --gateway, then run ocx route to sync"
- : "turn it off with ocx integrations native claude-desktop off";
- return { ...base, outcome: "conflict", message: `Claude Desktop is in first-party mode; ${command}` };
-}
-
-/**
- * Caller holds the lifecycle fence; the config coordinator rechecks guard before each commit.
- * `recordStandalone` is true only for a Child's link write: it records the standalone value
- * write-ahead so disconnect or a failed enrollment can restore it. Home writes are the
- * operator's own decisions and are not recorded.
- */
-export function persistNativeRouteIntent(client: "codex" | "claude" | "claude-desktop" | "grok", enabled: boolean, guard: () => void, snapshot: OcxConfig,
- recordStandalone: boolean): OcxConfig {
- const now = new Date();
- const apply = (config: OcxConfig): boolean => {
- const previous = client === "claude" ? config.claudeCode?.enabled !== false : config.clientIntegrations?.[client] !== false;
- if (previous === enabled) return false;
- if (client === "claude") {
- // The legacy flag also supplied the shared Messages API default. A local opt-out
- // must preserve that preexisting exposure for other devices and Desktop clients.
- if (!enabled && resolveApiSurfaceSettings(config).messages.source === "claude-code-legacy") {
- config.apiSurfaces = { ...config.apiSurfaces, messages: { enabled: resolveApiSurfaceSettings(config).messages.enabled } };
- }
- commitClaudeCodeBlock(config, { ...config.claudeCode, enabled }, now);
- }
- else config.clientIntegrations = { ...config.clientIntegrations, [client]: enabled };
- return true;
- };
- const result = withConfigMutationLockSync(() => {
- let recorded = false;
- if (recordStandalone) {
- guard();
- const base = readConfigForAdmittedMutation(), planned = structuredClone(base);
- if (apply(planned)) recorded = prepareNativeIntentWrite(nativeIntentFields(base), nativeIntentFields(planned));
- }
- const outcome = mutatePersistedConfig(config => {
- guard();
- return { changed: apply(config), value: config };
- });
- if (outcome.status !== "unavailable" && recorded) publishNativeIntentWrite(nativeIntentFields(outcome.value));
- return outcome;
- });
- if (result.status === "unavailable") throw new Error(`Local native route intent could not be persisted (${result.reason})`);
- if (client === "claude") {
- snapshot.claudeCode = { ...snapshot.claudeCode, enabled,
- ...(result.value.claudeCode?.authModeMigratedAt ? { authModeMigratedAt: result.value.claudeCode.authModeMigratedAt } : {}) };
- if (result.value.apiSurfaces?.messages !== undefined) snapshot.apiSurfaces = { ...snapshot.apiSurfaces, messages: { ...result.value.apiSurfaces.messages } };
- }
- else if (result.value.clientIntegrations) snapshot.clientIntegrations = { ...result.value.clientIntegrations };
- else delete snapshot.clientIntegrations;
- return result.value;
-}
-
-async function applyNative(
- input: RouteIntentApplyInput, options: LocalRouteIntentOptions, client: "codex" | "claude" | "claude-desktop" | "grok", intent: RouteClientIntent,
-): Promise {
- const guard = input.beforeWrite!;
- const home = input.home ?? homedir();
- const env = input.env ?? process.env;
- const desktopMode = client === "claude-desktop" ? await import("../claude/desktop-first-party") : undefined;
- const homeSwitch = liveHomeSwitch(input.document, client, options);
- // The committed flag reaches the caller's working snapshot too. The management owner mirrors
- // these individual flags onto its live config; a copied request config cannot do that for it.
- const committedConfig = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
- guard();
- if (desktopMode) {
- const current = loadConfig();
- if (desktopMode.resolveClaudeDesktopMode(current, desktopMode.observeClaudeDesktopMode(current)) === "first-party") return null;
- }
- if (homeSwitch) return loadConfig();
- // Only a connected Child's write displaces a standalone value; the Home owns its own flags.
- return persistNativeRouteIntent(client, intent.enabled, guard, input.config, readClientConnectionState().kind === "connected");
- }), options.lifecycleLockDeps);
- if (committedConfig === null) return firstPartyDesktopOutcome(intent.enabled);
- if (client === "codex") {
- const { getCodexHome, CODEX_CONFIG_PATH, DEFAULT_CATALOG_PATH } = await import("../codex/paths");
- const { injectCodexConfig, restoreNativeCodexAsync, currentExternalCodexModelProvider, isCodexRoutingInjected } = await import("../codex/inject");
- const target = CODEX_CONFIG_PATH;
- guard();
- if (!existsSync(getCodexHome())) return { client, target, outcome: intent.enabled ? "not_installed" : "current" };
- if (intent.enabled && options.codexApplied) return { client, target, outcome: "current" };
- const externalProvider = currentExternalCodexModelProvider();
- if (externalProvider) {
- const { approvedCodexProviders } = await import("../codex/provider-routing");
- if (!approvedCodexProviders().includes(externalProvider)) return { client, target, outcome: intent.enabled ? "conflict" : "current", message: "Codex has an external provider" };
- }
- if (!intent.enabled && !isCodexRoutingInjected()) return { client, target, outcome: "current" };
- const config = committedConfig;
- if (!intent.enabled) {
- const result = await restoreNativeCodexAsync({ withWriteGuard: work => withConfigMutationLockSync(() => { guard(); return work(); }) });
- return { client, target, outcome: result.success ? "current" : "failed", message: result.message };
- }
- const state = readClientConnectionState();
- const result = await injectCodexConfig(input.config.port, { ...config, syncResumeHistory: false }, {
- catalogPath: DEFAULT_CATALOG_PATH, beforeClientWrite: guard,
- ...(state.kind === "connected" ? { journalOwner: { kind: "client" as const, apiKeyId: state.value.apiKeyId },
- routingTarget: { baseUrl: `http://127.0.0.1:${input.config.port}/v1`, requiresAdmissionToken: false, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const } } : {}),
- });
- return { client, target, outcome: result.success && result.status !== "skipped" ? "current" : "failed", message: result.message };
- }
- if (client === "claude") {
- const target = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
- if (!existsSync(target)) return { client, target, outcome: intent.enabled ? "not_installed" : "current" };
- if (!intent.enabled) {
- const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
- const cleaned = withClientLifecycleSync(() => { guard(); return injectClaudeAgentDefs(committedConfig, {}, target); }, options.lifecycleLockDeps);
- if (cleaned === null) return { client, target, outcome: "failed", message: "Owned Claude agent definitions could not be removed" };
- }
- // Claude Code uses the existing launcher and selectedClients gate; no foreign config is copied.
- return { client, target, outcome: "current" };
- }
- if (client === "grok") {
- const { injectGrokConfig, stripGrokConfig } = await import("../grok/inject");
- const { inspectGrokConfig } = await import("../grok/inspect");
- const grokHome = env.GROK_HOME || join(home, ".grok");
- const target = join(grokHome, "config.toml");
- if (implicitHomeIntent(input.document, client, intent, options) && !localClientSyncAllowed(input.config)) {
- return { client, target, outcome: "current", message: "Grok sync is gated on this Home, as it is at startup" };
- }
- return withClientLifecycleSync(() => withConfigMutationLockSync(() => {
- guard();
- const state = inspectGrokConfig({ grokHome });
- if (state.kind === "not_installed") return { client, target, outcome: intent.enabled ? "not_installed" : "current" };
- if (state.kind === "orphaned_marker") return { client, target, outcome: "conflict", message: "Grok managed block is incomplete" };
- if (!intent.enabled && state.kind === "absent") return { client, target, outcome: "current" };
- const destination = localInferenceDestination(input.config, input.config.port);
- if (intent.enabled && destination.requiresAdmissionToken) return { client, target, outcome: "failed", message: "Grok requires a credential-free local listener" };
- const result = intent.enabled ? injectGrokConfig(destination.port, input.models.map(model => ({
- id: model.namespaced, name: model.displayName, contextWindow: model.contextWindow,
- reasoningEfforts: model.reasoningEfforts, defaultReasoningEffort: model.defaultReasoningEffort,
- })), { grokHome, hostname: "127.0.0.1" }) : stripGrokConfig({ grokHome });
- return { client, target, outcome: result.ok && !result.skippedReason ? "current" : "failed", message: result.message };
- }), options.lifecycleLockDeps);
- }
- const desktop = await import("../claude/desktop-3p");
- guard();
- // The durable marker: a caller's snapshot can predate a Desktop apply by another process.
- const inspected = desktop.inspectDesktop3pConfigLibrary({ appliedFingerprint: committedConfig.claudeCode?.desktopProfile?.appliedFingerprint });
- const target = inspected.selectedProfilePath ?? inspected.libraryPath;
- if (inspected.kind === "not_installed") return { client, target, outcome: intent.enabled ? "not_installed" : "current" };
- const state = readClientConnectionState();
- // Connected profiles carry their own owner/projection evidence, not the standalone marker.
- if (!intent.enabled && state.kind === "connected") {
- guard();
- const result = desktop.removeDesktop3pStandardPivot({ lifecycleLockDeps: options.lifecycleLockDeps });
- return { client, target, outcome: result.ok ? "current" : result.reason === "conflict" ? "conflict" : "failed" };
- }
- const firstParty = (config: OcxConfig) => desktopMode!.resolveClaudeDesktopMode(config, desktopMode!.observeClaudeDesktopMode(config)) === "first-party";
- const modeConflict: RouteClientOutcome = { client, target, outcome: "conflict", message: "Claude Desktop first-party mode needs its existing mode-switch transaction" };
- if (intent.enabled && state.kind === "connected") {
- const { applyRemoteDesktopStore } = await import("../claude/desktop-remote-store");
- const { readServiceApiTokenState } = await import("../lib/service-secrets");
- const models = desktop.generateDesktop3pModels(input.models.filter(model => model.native).map(model => model.id),
- input.models.filter(model => !model.native).map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })));
- return withClientLifecycleSync(held => withConfigMutationLockSync(() => {
- guard();
- if (firstParty(loadConfig())) return firstPartyDesktopOutcome(true, target);
- const token = readServiceApiTokenState();
- if (token.kind !== "present") return { client, target, outcome: "failed", message: "Child service credential is unavailable" };
- const result = applyRemoteDesktopStore(held, {
- owner: { serverUrl: state.value.serverUrl, apiKeyId: state.value.apiKeyId, connectedAt: state.value.connectedAt },
- expectedTokenFingerprint: token.fingerprint, baseUrl: `http://127.0.0.1:${input.config.port}`,
- apiKey: "opencodex-loopback", mode: "static", models, localListener: true, overwriteConflict: options.overwriteConflict,
- });
- return { client, target: result.ok ? result.path ?? target : target, outcome: result.ok ? "current" : result.reason === "conflict" ? "conflict" : "failed" };
- }), options.lifecycleLockDeps);
- }
- if (!intent.enabled && inspected.kind === "no_owned_state") return { client, target, outcome: "current" };
- const appliedFingerprint = committedConfig.claudeCode?.desktopProfile?.appliedFingerprint;
- const refreshOnly = implicitHomeIntent(input.document, client, intent, options);
- const changedProfile = ["unsafe", "broken", "gateway_drifted"].includes(inspected.kind);
- if (changedProfile && refreshOnly) {
- // Only an OpenCodex-owned profile can have changed under a refresh. An unreadable library or one
- // selecting another app's profile was never ours, so the refresh has nothing to do there.
- const owned = inspected.ownedProfileActive === true || (inspected.ownedProfileActive === null && !!appliedFingerprint);
- if (!owned) return { client, target, outcome: "current", message: IMPLICIT_DESKTOP_MESSAGE };
- // A refresh never takes over a changed profile, even with overwrite: that is a decided write.
- return { client, target, outcome: "conflict",
- message: "Claude Desktop ownership or profile changed; replace it with ocx route to claude-desktop on --device home --overwrite-conflict" };
- }
- if (changedProfile && !options.overwriteConflict) return { client, target, outcome: "conflict", message: "Claude Desktop ownership or profile changed" };
- // Only an absent or untouched profile satisfies a refresh-only switch.
- if (refreshOnly && (inspected.kind !== "gateway_ours" || !appliedFingerprint)) return { client, target, outcome: "current", message: IMPLICIT_DESKTOP_MESSAGE };
- if (!intent.enabled) {
- guard();
- const result = desktop.removeDesktop3pStandardPivot({ appliedFingerprint: committedConfig.claudeCode?.desktopProfile?.appliedFingerprint, lifecycleLockDeps: options.lifecycleLockDeps });
- return { client, target, outcome: result.ok ? "current" : "failed" };
- }
- const { getClaudePickerController } = await import("../claude/intercept/runtime");
- const { runDesktopTransition } = await import("../claude/desktop-picker");
- // Mode switches share picker exclusion in-process and L→C across CLI/server writers.
- // Keep the file write and marker together, reusing the existing writer with this lease.
- return runDesktopTransition(getClaudePickerController(), async () => withClientLifecycleSync(held => withConfigMutationLockSync(() => {
- guard();
- const config = loadConfig();
- if (firstParty(config)) return firstPartyDesktopOutcome(true, target);
- const profile = config.claudeCode?.desktopProfile;
- const result = desktop.writeDesktop3pConfig(input.config.port,
- input.models.filter(model => model.native).map(model => model.id),
- input.models.filter(model => !model.native).map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })),
- undefined, "static", profile, undefined, options.lifecycleLockDeps,
- refreshOnly ? { appliedFingerprint: appliedFingerprint!, admit: () => !firstParty(loadConfig()) } : undefined, held);
- if (refreshOnly && result.reason === "desktop_refresh_only_skipped") return { client, target, outcome: "current", message: IMPLICIT_DESKTOP_MESSAGE };
- if (!result.written) return { client, target, outcome: "failed", message: result.reason ?? "Claude Desktop writer refused" };
- guard();
- if (firstParty(loadConfig())) return modeConflict;
- if (refreshOnly) {
- // A refresh keeps the existing mode; only the applied marker follows the new bytes.
- const marked = commitDesktopAppliedMarker(captureDesktopAppliedMarker(profile), result.fingerprint!);
- return { client, target, outcome: marked.status !== "unavailable" && marked.value ? "current" : "failed" };
- }
- const recorded = persistCommittedDesktopGateway(config, config.claudeCode?.desktopProfile, result.fingerprint, current => !firstParty(current));
- return { client, target, outcome: recorded.ok ? "current" : "failed" };
- }), options.lifecycleLockDeps), { config: committedConfig });
-}
-
-export function materializeLocalRouteIntent(
- config: OcxConfig, document: HomeRouteIntentDocument, models: readonly ExportModel[], options: LocalRouteIntentOptions = {},
-): Promise {
- const expected = readClientConnectionState();
- const configuredPort = loadConfig().port;
- const beforeWrite = () => {
- options.beforeWrite?.();
- assertRouteIntentPort(config.port);
- if (loadConfig().port !== configuredPort) throw new Error("client_runtime_changed");
- assertNoClientDisconnectPending();
- const current = readClientConnectionState();
- if (expected.kind === "connected" ? current.kind !== "connected" || !sameClientConnectionOwner(current.value, expected.value)
- : current.kind !== expected.kind || current.kind !== "disconnected") throw new Error("client_connection_changed");
- };
- const input: RouteIntentApplyInput = { config, document: structuredClone(document), models: structuredClone([...models]), ...options, beforeWrite,
- ...(expected.kind === "connected" && expected.value.link ? { linkId: expected.value.link.linkId } : {}),
- localOff: expected.kind === "connected" && expected.value.transport === "link" ? undefined : [] };
- return materializeRouteIntent({ ...input, withFileWrites: work => withClientLifecycle(work, options.lifecycleLockDeps),
- withReceiptWrite: write => withClientLifecycleSync(() => { beforeWrite(); write(); }, options.lifecycleLockDeps),
- applyNative: (client, intent) => applyNative(input, { ...options, overwriteConflict: options.overwriteConflict === true && (options.overwriteClient === undefined || options.overwriteClient === client) }, client, intent) });
-}
diff --git a/src/client/route-intent-file-originals.ts b/src/client/route-intent-file-originals.ts
deleted file mode 100644
index ee0b1c0fa9f..00000000000
--- a/src/client/route-intent-file-originals.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * File integrations a linked Child owned before linking.
- *
- * A Child applying its Home's route intent disables, refreshes or creates file-integration blocks.
- * Without this record, disconnect cannot tell a block the link created from one the person had
- * set up on their own, and removed both. At the first link apply, before any client changes, the
- * Child records which file clients had an ownership record (with their model-name policy). The
- * record is private, bound to the link that captured it, and carries no paths or secrets.
- *
- * Disconnect restores those clients to their standalone state: a client the link left owned is
- * kept as is, a client the link disabled is applied again, and a client the person changed in the
- * meantime (a local opt-out, or a block of their own) is left alone. Clients the link created keep
- * the ordinary cleanup. Aside profiles are not recorded and keep that cleanup too.
- *
- * Callers hold the client lifecycle lock; reads are strict, so a damaged record refuses the
- * disconnect instead of silently dropping a standalone integration.
- */
-import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
-import { join } from "node:path";
-import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
-import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
-import { hardenSecretDir } from "../lib/windows-secret-acl";
-import { linkDir } from "../link/paths";
-import { ROUTE_CLIENT_IDS, type RouteClientId } from "../link/route-intent";
-
-type ModelNames = "alias" | "routed";
-export interface FileIntentOriginals {
- version: 1;
- linkId: string;
- clients: Partial>;
-}
-
-const RECORD_MAX_BYTES = 16 * 1024;
-const NOT_FILE_CLIENTS: readonly RouteClientId[] = ["codex", "claude", "claude-desktop", "grok", "aside"];
-const FILE_CLIENTS = ROUTE_CLIENT_IDS.filter(client => !NOT_FILE_CLIENTS.includes(client));
-const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value);
-
-export function fileIntentOriginalsPath(): string { return join(linkDir(), "file-intent-originals.json"); }
-
-const unsafe = (): never => {
- throw new Error(`File integration originals record is unreadable; inspect or remove ${fileIntentOriginalsPath()} and retry`);
-};
-
-function parse(value: unknown): FileIntentOriginals {
- if (!isRecord(value) || value.version !== 1 || typeof value.linkId !== "string" || !/^lnk_[0-9a-f]{16}$/.test(value.linkId)
- || !isRecord(value.clients) || Object.keys(value).length !== 3) return unsafe();
- for (const [client, entry] of Object.entries(value.clients)) {
- if (!(FILE_CLIENTS as readonly string[]).includes(client) || !isRecord(entry)
- || Object.keys(entry).some(key => key !== "modelNames")
- || (entry.modelNames !== undefined && entry.modelNames !== "alias" && entry.modelNames !== "routed")) return unsafe();
- }
- return value as unknown as FileIntentOriginals;
-}
-
-/** Missing reads as null; a present but malformed record refuses. */
-export function readFileIntentOriginals(): FileIntentOriginals | null {
- let stat;
- try { stat = lstatSync(fileIntentOriginalsPath()); } catch (error) { if (isMissingPathError(error)) return null; throw error; }
- if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return unsafe();
- let value: unknown;
- try { value = JSON.parse(readFileSync(fileIntentOriginalsPath(), "utf8")); } catch { return unsafe(); }
- return parse(value);
-}
-
-function writeRecord(record: FileIntentOriginals): void {
- parse(record);
- const dir = linkDir();
- assertNotRealHomeUnderTest(join(dir, ".."));
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- if (process.platform === "win32") hardenSecretDir(dir, { required: true });
- else chmodSync(dir, 0o700);
- atomicWriteFile(fileIntentOriginalsPath(), `${JSON.stringify(record, null, 2)}\n`);
- if (process.platform !== "win32") chmodSync(fileIntentOriginalsPath(), 0o600);
-}
-
-export function clearFileIntentOriginals(): void {
- assertNotRealHomeUnderTest(join(linkDir(), ".."));
- try { unlinkSync(fileIntentOriginalsPath()); } catch (error) { if (!isMissingPathError(error)) throw error; }
-}
-
-/**
- * Write-ahead capture for a link's first apply. `firstApply` is false once a route receipt bound
- * to a link exists, so clients a link created are never recorded as standalone. A record from
- * another link is stale and is replaced.
- */
-export function captureFileIntentOriginals(linkId: string, firstApply: boolean,
- owners: Partial>): void {
- const existing = readFileIntentOriginals();
- if (existing?.linkId === linkId || !firstApply) return;
- const clients: FileIntentOriginals["clients"] = {};
- for (const client of FILE_CLIENTS) {
- const owner = owners[client];
- if (owner) clients[client] = owner.modelNames ? { modelNames: owner.modelNames } : {};
- }
- if (Object.keys(clients).length === 0) { if (existing) clearFileIntentOriginals(); return; }
- writeRecord({ version: 1, linkId, clients });
-}
diff --git a/src/client/route-intent-native-originals.ts b/src/client/route-intent-native-originals.ts
deleted file mode 100644
index 2e73e1001d8..00000000000
--- a/src/client/route-intent-native-originals.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-/**
- * Standalone native originals for a linked Child.
- *
- * A Child applying its Home's route intent writes the Child's own native switches
- * (clientIntegrations.codex|grok|claude-desktop, claudeCode.enabled, and as side effects
- * claudeCode.authModeMigratedAt and apiSurfaces.messages). This private, machine-scoped
- * record remembers, per field, the value that existed before route intent first changed it
- * (absent is a value: null), plus the last value this writer published and any value it was
- * about to publish (pending). It is captured once and never overwritten by later syncs or
- * connects, and it is not bound to a link: it means "the standalone value before route intent
- * touched it", which stays true after a crashed connect followed by a different link.
- *
- * Callers hold the client lifecycle lock; every function here takes the config mutation lock,
- * preserving the documented lifecycle -> config order. Writes are write-ahead: the original and
- * pending value reach this file before the atomic config write, and the value is marked
- * published after it. Restore (disconnect, failed enrollment) puts the original back only for a
- * field whose current value this writer published or had pending; any other value is the
- * person's own edit and is kept. The record is removed only after the restored config is read
- * back and verified, so an interrupted restore is retried safely by the next disconnect.
- *
- * Limit (ABA): a person who sets a field to exactly the value this writer published cannot be
- * told apart from the writer, and that field is restored. The Child's local opt-out list is the
- * person's own choice and is not part of this record.
- */
-import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
-import { join } from "node:path";
-import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
-import { withConfigMutationLockSync } from "../config/mutation-lock";
-import { mutatePersistedConfig, readConfigForAdmittedMutation } from "../config/persisted-mutation";
-import { deleteConfigObjectChildKey } from "../config/rebase-provenance";
-import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
-import { hardenSecretDir } from "../lib/windows-secret-acl";
-import { linkDir } from "../link/paths";
-import type { OcxConfig } from "../types";
-
-export const NATIVE_INTENT_FIELDS = ["codex", "grok", "claude-desktop", "claude", "claudeMigratedAt", "messages"] as const;
-export type NativeIntentField = typeof NATIVE_INTENT_FIELDS[number];
-/** null means the key is absent; anything else is the exact persisted value. */
-export type NativeIntentSlot = boolean | string | Record | null;
-export type NativeIntentFields = Record;
-type Entry = { original: NativeIntentSlot; published?: NativeIntentSlot; pending?: NativeIntentSlot };
-export type NativeIntentOriginals = { version: 1; fields: Partial> };
-
-const RECORD_MAX_BYTES = 16 * 1024;
-const unsafe = (): never => {
- throw new Error(`Native intent originals record is unreadable; inspect or remove ${nativeIntentOriginalsPath()} and retry`);
-};
-const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value);
-const sameSlot = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
-
-export function nativeIntentOriginalsPath(): string { return join(linkDir(), "native-intent-originals.json"); }
-
-const integrationField = (field: NativeIntentField): field is "codex" | "grok" | "claude-desktop" =>
- field === "codex" || field === "grok" || field === "claude-desktop";
-const parentKey = (field: NativeIntentField) => integrationField(field) ? "clientIntegrations" as const
- : field === "messages" ? "apiSurfaces" as const : "claudeCode" as const;
-const childKey = (field: NativeIntentField): string => integrationField(field) ? field
- : field === "messages" ? "messages" : field === "claude" ? "enabled" : "authModeMigratedAt";
-
-export function readNativeIntentField(config: OcxConfig, field: NativeIntentField): NativeIntentSlot {
- const parent = (config as unknown as Record)[parentKey(field)];
- if (!isRecord(parent) || !Object.hasOwn(parent, childKey(field)) || parent[childKey(field)] === undefined) return null;
- return structuredClone(parent[childKey(field)]) as NativeIntentSlot;
-}
-
-export function nativeIntentFields(config: OcxConfig): NativeIntentFields {
- return Object.fromEntries(NATIVE_INTENT_FIELDS.map(field => [field, readNativeIntentField(config, field)])) as NativeIntentFields;
-}
-
-function writeNativeIntentField(config: OcxConfig, field: NativeIntentField, value: NativeIntentSlot): void {
- const key = parentKey(field);
- if (value === null) { deleteConfigObjectChildKey(config, key, childKey(field)); return; }
- const record = config as unknown as Record;
- const parent = isRecord(record[key]) ? record[key] as Record : {};
- record[key] = { ...parent, [childKey(field)]: structuredClone(value) };
-}
-
-function validSlot(field: NativeIntentField, value: unknown): value is NativeIntentSlot {
- if (value === null) return true;
- if (field === "claudeMigratedAt") return typeof value === "string" && value.length <= 64;
- if (field === "messages") return isRecord(value) && Object.values(value).every(item => item === null || ["boolean", "string", "number"].includes(typeof item));
- return typeof value === "boolean";
-}
-
-function parse(value: unknown): NativeIntentOriginals {
- if (!isRecord(value) || value.version !== 1 || !isRecord(value.fields) || Object.keys(value).length !== 2) return unsafe();
- for (const [field, entry] of Object.entries(value.fields)) {
- if (!(NATIVE_INTENT_FIELDS as readonly string[]).includes(field) || !isRecord(entry) || !Object.hasOwn(entry, "original")) return unsafe();
- for (const [key, slot] of Object.entries(entry)) {
- if (!["original", "published", "pending"].includes(key) || !validSlot(field as NativeIntentField, slot)) return unsafe();
- }
- }
- return value as NativeIntentOriginals;
-}
-
-/** Missing reads as null. A present but unsafe or malformed record refuses rather than losing originals. */
-export function readNativeIntentOriginals(): NativeIntentOriginals | null {
- const path = nativeIntentOriginalsPath();
- let stat;
- try { stat = lstatSync(path); } catch (error) { if (isMissingPathError(error)) return null; throw error; }
- if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return unsafe();
- let value: unknown;
- try { value = JSON.parse(readFileSync(path, "utf8")); } catch { return unsafe(); }
- return parse(value);
-}
-
-function writeRecord(record: NativeIntentOriginals): void {
- parse(record);
- const path = nativeIntentOriginalsPath(), dir = linkDir();
- assertNotRealHomeUnderTest(join(dir, ".."));
- const body = `${JSON.stringify(record, null, 2)}\n`;
- if (Buffer.byteLength(body) > RECORD_MAX_BYTES) unsafe();
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- if (process.platform === "win32") hardenSecretDir(dir, { required: true });
- else chmodSync(dir, 0o700);
- atomicWriteFile(path, body);
- if (process.platform !== "win32") chmodSync(path, 0o600);
-}
-
-function clearRecord(): void {
- assertNotRealHomeUnderTest(join(linkDir(), ".."));
- try { unlinkSync(nativeIntentOriginalsPath()); } catch (error) { if (!isMissingPathError(error)) throw error; }
-}
-
-/** True when the value is one this writer published or was about to publish. */
-function ownedByWriter(entry: Entry, current: NativeIntentSlot): boolean {
- return (Object.hasOwn(entry, "published") && sameSlot(entry.published, current))
- || (Object.hasOwn(entry, "pending") && sameSlot(entry.pending, current));
-}
-
-/**
- * Write-ahead half: call under the config mutation lock before the atomic config write. Only
- * fields that actually change are recorded, so a no-op toggle claims nothing. The first capture
- * of a field keeps its original forever; a pending value a crash left behind and the config
- * shows is promoted to published first.
- */
-export function prepareNativeIntentWrite(before: NativeIntentFields, after: NativeIntentFields): boolean {
- return withConfigMutationLockSync(() => {
- const changed = NATIVE_INTENT_FIELDS.filter(field => !sameSlot(before[field], after[field]));
- if (!changed.length) return false;
- const record = readNativeIntentOriginals() ?? { version: 1 as const, fields: {} };
- for (const field of changed) {
- const entry: Entry = record.fields[field] ?? { original: structuredClone(before[field]) };
- if (Object.hasOwn(entry, "pending") && sameSlot(entry.pending, before[field])) entry.published = entry.pending;
- entry.pending = structuredClone(after[field]);
- record.fields[field] = entry;
- }
- writeRecord(record);
- return true;
- });
-}
-
-/** Completion half: mark each pending value the committed config now shows as published. */
-export function publishNativeIntentWrite(actual: NativeIntentFields): void {
- withConfigMutationLockSync(() => {
- const record = readNativeIntentOriginals();
- if (!record) return;
- let changed = false;
- for (const field of NATIVE_INTENT_FIELDS) {
- const entry = record.fields[field];
- if (!entry || !Object.hasOwn(entry, "pending") || !sameSlot(entry.pending, actual[field])) continue;
- entry.published = entry.pending;
- delete entry.pending;
- changed = true;
- }
- if (changed) writeRecord(record);
- });
-}
-
-/**
- * Restore standalone values and remove the record once the restored config is verified.
- * Idempotent: a retry after an interrupted restore finds the original or the person's own
- * value and only removes the record. Returns whether a record existed.
- */
-export function restoreNativeIntentOriginals(guard: () => void): boolean {
- return withConfigMutationLockSync(() => {
- guard();
- const record = readNativeIntentOriginals();
- if (!record) return false;
- const fields = Object.entries(record.fields) as [NativeIntentField, Entry][];
- const result = mutatePersistedConfig(config => {
- guard();
- let changed = false;
- for (const [field, entry] of fields) {
- const current = readNativeIntentField(config, field);
- if (!ownedByWriter(entry, current) || sameSlot(current, entry.original)) continue;
- writeNativeIntentField(config, field, entry.original);
- changed = true;
- }
- return { changed, value: config };
- });
- if (result.status === "unavailable") throw new Error(`Standalone native intent could not be restored (${result.reason})`);
- const verified = nativeIntentFields(readConfigForAdmittedMutation());
- if (fields.some(([field, entry]) => ownedByWriter(entry, verified[field]) && !sameSlot(verified[field], entry.original))) {
- throw new Error("Standalone native intent restore was not verified; rerun the operation");
- }
- guard();
- clearRecord();
- return true;
- });
-}
diff --git a/src/client/route-intent-watch.ts b/src/client/route-intent-watch.ts
deleted file mode 100644
index e5119332bb3..00000000000
--- a/src/client/route-intent-watch.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-/** Child-owned watch: authenticated reads only; local reconciliation stays with the runtime scheduler. */
-import type { OcxClientConnectionConfig } from "../types";
-import { LINK_ROUTE_INTENT_PATH } from "../link/management-routes";
-import { parseHomeRouteIntentDocument, routeIntentDigest, ROUTE_INTENT_DIGEST_PATTERN, type HomeRouteIntentDocument } from "../link/route-intent";
-import { ROUTE_INTENT_AFTER_HEADER, ROUTE_INTENT_WAKE_HEADER, ROUTE_INTENT_WATCH_HEADER } from "../link/route-intent-watch";
-import { readBoundedResponseBytes } from "../lib/bounded-body";
-import { signalWithTimeout } from "../lib/abort";
-import { fetchBoundLinkRelay } from "./link-relay-transport";
-
-const WATCH_READ_TIMEOUT_MS = 40_000; // Home wait (30 s), relay proof and bounded body share one budget.
-const WATCH_MAX_BYTES = 64 * 1024;
-const WATCH_MAX_HEADER_BYTES = 8 * 1024;
-const WATCH_RETRY_MS = 5_000;
-export type RouteIntentWatchKey = string | (() => string | null);
-export interface RouteIntentWatchReadDeps {
- /** Trusted complete-send test seam; production always uses the connection-bound relay. */
- fetchImpl?: typeof fetch;
- signal?: AbortSignal;
- timeoutMs?: number;
-}
-export type RouteIntentWatchResult = { kind: "fresh"; document: HomeRouteIntentDocument; watchSupported: boolean; wakeReason?: "changed" | "notified" | "timeout" }
- | { kind: "unsupported" } | { kind: "cancelled" } | { kind: "failed"; reason: "route_intent_watch_unavailable" };
-
-function cancel(response: Response): void {
- try { void response.body?.cancel().catch(() => {}); } catch { /* no untrusted cleanup can extend the deadline */ }
-}
-/** No selectors, redirects, management writes, cached fallback or durable receipt writes. */
-export async function watchHomeRouteIntent(
- connection: OcxClientConnectionConfig, key: RouteIntentWatchKey, afterDigest?: string, deps: RouteIntentWatchReadDeps = {},
-): Promise {
- const failed = (): RouteIntentWatchResult => ({ kind: "failed", reason: "route_intent_watch_unavailable" });
- if (deps.signal?.aborted) return { kind: "cancelled" };
- if (connection.transport !== "link" || !connection.link || !connection.apiKeyId
- || (afterDigest !== undefined && !ROUTE_INTENT_DIGEST_PATTERN.test(afterDigest))) return failed();
- const budget = deps.timeoutMs ?? WATCH_READ_TIMEOUT_MS;
- if (!Number.isInteger(budget) || budget < 1 || budget > WATCH_READ_TIMEOUT_MS) return failed();
- const deadline = signalWithTimeout(budget, deps.signal);
- let response: Response | undefined;
- try {
- // A long-lived runtime survives key rotation. Never retain the resolved credential across GETs.
- const admissionKey = typeof key === "function" ? key() : key;
- if (!admissionKey) return failed();
- const target = { tunnelPort: connection.link.tunnelPort, linkId: connection.link.linkId, apiKeyId: connection.apiKeyId, admissionKey };
- const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_ROUTE_INTENT_PATH}`;
- const init: RequestInit = { method: "GET", redirect: "manual", signal: deadline.signal,
- headers: { accept: "application/json", authorization: `Bearer ${admissionKey}`, [ROUTE_INTENT_WATCH_HEADER]: "1",
- ...(afterDigest === undefined ? {} : { [ROUTE_INTENT_AFTER_HEADER]: afterDigest }) } };
- response = deps.fetchImpl ? await deps.fetchImpl(destination, init) : await fetchBoundLinkRelay(target, destination, init);
- if (response.status === 404) return { kind: "unsupported" };
- if (response.status !== 200) return failed();
- let headerBytes = 0;
- for (const [name, value] of response.headers) headerBytes += Buffer.byteLength(name) + Buffer.byteLength(value) + 4;
- const support = response.headers.get(ROUTE_INTENT_WATCH_HEADER);
- const wakeReason = response.headers.get(ROUTE_INTENT_WAKE_HEADER);
- const declared = response.headers.get("content-length");
- if (headerBytes > WATCH_MAX_HEADER_BYTES || (support !== null && support !== "1")
- || (wakeReason !== null && wakeReason !== "changed" && wakeReason !== "notified" && wakeReason !== "timeout")
- || response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json"
- || (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > WATCH_MAX_BYTES))) return failed();
- const read = await readBoundedResponseBytes(response, { maxBytes: WATCH_MAX_BYTES, signal: deadline.signal });
- if (read.oversized) return failed();
- const document = parseHomeRouteIntentDocument(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(read.bytes)));
- return document ? { kind: "fresh", document, watchSupported: support === "1", ...(wakeReason === null ? {} : { wakeReason }) } : failed();
- } catch { return deps.signal?.aborted ? { kind: "cancelled" } : failed(); }
- finally { if (response) cancel(response); deadline.cleanup(); }
-}
-
-export interface RouteIntentRuntimeWatchDeps extends Omit {
- /** Share this with the periodic tick. Resolve after apply/report; reject busy/failure to retain the event. */
- requestSync: () => Promise;
- setTimer?: (callback: () => void, ms: number) => unknown;
- clearTimer?: (timer: unknown) => void;
-}
-export interface RouteIntentRuntimeWatch { stop(): void }
-const liveWatches = new Map();
-
-/** One GET per live link; one in-flight scheduler request plus one coalesced pending event. */
-export function startRouteIntentWatch(
- connection: OcxClientConnectionConfig, key: RouteIntentWatchKey, deps: RouteIntentRuntimeWatchDeps,
-): RouteIntentRuntimeWatch {
- if (connection.transport !== "link" || !connection.link) return { stop() {} };
- const linkId = connection.link.linkId;
- const existing = liveWatches.get(linkId);
- if (existing) return existing;
- // Do not let caller mutation redirect a long-lived watch onto a different connection.
- const owner = structuredClone(connection);
- const abort = new AbortController();
- const set = deps.setTimer ?? ((cb, ms) => { const timer = setTimeout(cb, ms); timer.unref(); return timer; });
- const clear = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType));
- let timer: unknown, retry: unknown;
- let stopped = false, syncing = false, pending = false;
- let after: string | undefined;
- const drain = async () => {
- if (stopped || syncing) return;
- syncing = true;
- try {
- while (pending && !stopped) {
- pending = false;
- try { await deps.requestSync(); }
- catch {
- pending = true;
- if (!stopped) retry = set(() => { retry = undefined; void drain(); }, WATCH_RETRY_MS);
- break;
- }
- }
- } finally { syncing = false; }
- };
- const request = () => {
- pending = true;
- if (retry === undefined) void drain();
- };
- const schedule = (ms: number) => { if (!stopped) timer = set(() => { timer = undefined; void poll(); }, ms); };
- const poll = async () => {
- if (stopped) return;
- const started = Date.now();
- const result = await watchHomeRouteIntent(owner, key, after, { ...deps, signal: abort.signal });
- if (stopped || result.kind === "cancelled") return;
- // A pre-watch Home answers immediately forever. Its existing periodic sync remains authoritative.
- if (result.kind === "unsupported" || (result.kind === "fresh" && !result.watchSupported)) return;
- if (result.kind === "failed") { schedule(WATCH_RETRY_MS); return; }
- const next = routeIntentDigest(result.document);
- const unchanged = after === next;
- after = next;
- if (!unchanged || result.wakeReason !== "timeout") request();
- // Broken peers advertising support but never waiting cannot make a tight loop.
- schedule(unchanged ? Math.max(0, 1_000 - (Date.now() - started)) : 0);
- };
- const handle: RouteIntentRuntimeWatch = { stop() {
- if (stopped) return;
- stopped = true; pending = false;
- abort.abort();
- if (timer !== undefined) clear(timer);
- if (retry !== undefined) clear(retry);
- timer = retry = undefined;
- liveWatches.delete(linkId);
- // An already-entered local apply is owned/fenced by requestSync, never cancelled mid-write here.
- } };
- liveWatches.set(linkId, handle);
- schedule(0);
- return handle;
-}
diff --git a/src/client/runtime.ts b/src/client/runtime.ts
index 1d1044f51ed..75e3462f147 100644
--- a/src/client/runtime.ts
+++ b/src/client/runtime.ts
@@ -34,15 +34,11 @@ import { clientRestartRollbackAction, clientRestartSupervisor, createClientResta
import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter";
import { restoreLinkClientRouting } from "./stop-routing";
import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime";
-import { startRouteIntentTick, type RouteIntentTick } from "../integrations/route-intent-tick";
-import { startRouteIntentWatch, type RouteIntentRuntimeWatch } from "./route-intent-watch";
let activeServer: Server | null = null;
let activePort: number | null = null;
let activeSupervisor: ClientLinkSupervisor | null = null;
let activeReporter: LinkParityReporter | null = null;
-let activeRouteWatch: RouteIntentRuntimeWatch | null = null;
-let activeRouteSync: RouteIntentTick | null = null;
let recycleScheduled = false;
let recycleTimer: ReturnType | undefined;
let recycleFlight: Promise | null = null;
@@ -171,9 +167,6 @@ export async function recycleStandalone(
const replacementEnv = port && process.env.OCX_SERVICE !== "1"
? withSiblingMarker(standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), issueSiblingHandoff)
: null;
- activeReporter?.stop(); activeReporter = null;
- activeRouteWatch?.stop(); activeRouteWatch = null;
- activeRouteSync?.stop(); activeRouteSync = null;
try {
await activeSupervisor?.stop();
} catch (error) {
@@ -384,9 +377,6 @@ export async function startClientRuntime(
if (linkMode && state.value.link && linkKey) {
activeReporter = createLinkParityReporter({ connection: state.value, linkKey, runtimeStartedAt: CLIENT_RUNTIME_STARTED_AT });
activeReporter.start();
- const scheduler = startRouteIntentTick(state.value, { onApplied: () => activeReporter?.requestReport() });
- activeRouteSync = scheduler;
- activeRouteWatch = startRouteIntentWatch(state.value, linkKey, { requestSync: () => scheduler.requestSync() });
}
};
if (!rollback) {
@@ -418,8 +408,6 @@ export async function startClientRuntime(
credentialObservation = null;
activeReporter?.stop();
activeReporter = null;
- activeRouteWatch?.stop(); activeRouteWatch = null;
- activeRouteSync?.stop(); activeRouteSync = null;
try {
await activeSupervisor?.stop();
} catch (error) {
@@ -499,8 +487,6 @@ export async function startClientRuntime(
credentialObservation = null;
activeReporter?.stop();
activeReporter = null;
- activeRouteWatch?.stop(); activeRouteWatch = null;
- activeRouteSync?.stop(); activeRouteSync = null;
try {
await activeSupervisor?.stop();
} catch (error) {
diff --git a/src/client/sync-runtime.ts b/src/client/sync-runtime.ts
index c11d93824fe..883b013b6e9 100644
--- a/src/client/sync-runtime.ts
+++ b/src/client/sync-runtime.ts
@@ -35,13 +35,6 @@ export function clientSyncRuntimeGuard(): () => void {
};
}
-/** Offline enrollment keeps the configured port. A published runtime must agree before apply. */
-export function assertRouteIntentPort(port: number): void {
- const runtime = readSyncRuntime();
- if (runtime?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping");
- if (runtime && runtime.port !== port) throw new ClientSyncRuntimeError("client_runtime_changed");
-}
-
/** The same C transaction excludes every sync commit; never mark a replacement runtime. */
export function markClientRuntimeStopping(expected: Required>): void {
withConfigMutationLockSync(() => {
diff --git a/src/codex/journal-config-restore.ts b/src/codex/journal-config-restore.ts
deleted file mode 100644
index 5f928131ee0..00000000000
--- a/src/codex/journal-config-restore.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { isDeepStrictEqual } from "node:util";
-import { chmodSync, closeSync, fsyncSync, mkdtempSync, openSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
-import { isOcxRoutingMarkerLine } from "./injected-marker";
-import { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "./subagent-defaults";
-import { rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "./toml-source-lines";
-
-const ROOT_KEYS = new Set(["openai_base_url", "experimental_realtime_ws_base_url", "model_catalog_json", "model_provider", "web_search"]);
-type Field = { start: number; end: number; text: string; value: unknown };
-export type JournalConfigFields = Record;
-
-/** Only injection-owned fields are captured; hooks, projects and paths never enter this map. */
-function fields(content: string): Map {
- const { lines, rootEnd } = rootSourceLines(content);
- // Inline/dotted tables have no independently replaceable owned block. Detect
- // them before restoring any root key; leaving them behind would lose recovery
- // evidence while the injected provider still exists. Decode key paths as TOML,
- // including quoted keys, without parsing unrelated (possibly wide) integers.
- let header = "";
- const keyToken = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
- const assignment = new RegExp(`^\\s*(${keyToken}(?:\\s*\\.\\s*${keyToken})*)\\s*=`);
- for (let at = 0; at < lines.length; at++) {
- const line = lines[at]!;
- if (!line.structural) continue;
- if (/^\s*\[/.test(line.text)) { header = line.text; continue; }
- const prefix = assignment.exec(line.text)?.[1];
- if (!prefix) continue;
- const context = Bun.TOML.parse(header) as Record;
- // Explicit owned blocks are handled losslessly by the block scan below.
- if (["model_providers", "profiles"].some(key => {
- const table = context[key];
- return table && typeof table === "object" && Object.hasOwn(table, "opencodex");
- })) continue;
- const shape = Bun.TOML.parse(`${header}\n${prefix} = {}`) as Record;
- for (const key of ["model_providers", "profiles"]) {
- const table = shape[key];
- if (!table || typeof table !== "object") continue;
- if (Object.hasOwn(table, "opencodex")) throw new Error("unsupported dotted owned Codex table");
- if (Object.keys(table).length) continue; // an unrelated provider/profile path
- let end = at + 1;
- while (end < lines.length && !lines[end]!.structural) end++;
- const value = Bun.TOML.parse(`${header}\n${sourceText(lines.slice(at, end))}`) as Record;
- const actual = value[key];
- if (actual && typeof actual === "object" && Object.hasOwn(actual, "opencodex")) {
- throw new Error("unsupported inline or dotted owned Codex table");
- }
- }
- }
- const found = new Map();
- const put = (key: string, value: Field) => {
- if (found.has(key)) throw new Error("duplicate owned Codex field");
- found.set(key, value);
- };
- for (let at = 0; at < rootEnd; at++) {
- if (!lines[at]!.structural) continue;
- const key = rootAssignmentKey(lines[at]!.text);
- if (!key || !ROOT_KEYS.has(key)) continue;
- const span = sourceAssignment(lines, at, rootEnd);
- if (!span) throw new Error("invalid owned Codex field");
- put(key, span);
- }
- for (let at = rootEnd; at < lines.length; at++) {
- const line = lines[at]!;
- if (!line.structural || !/^\s*\[/.test(line.text)) continue;
- const header = Bun.TOML.parse(line.text) as Record;
- const target = ["model_providers", "profiles"].find(key => {
- const table = header[key];
- return table && typeof table === "object" && Object.hasOwn(table, "opencodex");
- });
- let end = at + 1;
- while (end < lines.length && !(lines[end]!.structural && /^\s*\[/.test(lines[end]!.text))) end++;
- // A marker adjacent to the next header belongs to that next block, never this one.
- if (end < lines.length && lines[end - 1]?.structural && isOcxRoutingMarkerLine(lines[end - 1]!.text)) end--;
- if (target) {
- const table = (header[target] as Record).opencodex;
- if (!table || typeof table !== "object" || Object.keys(table).length) throw new Error("unsupported owned child table");
- const text = sourceText(lines.slice(at, end));
- // Child tables are refused rather than accidentally treating them as the parent block.
- const value = Bun.TOML.parse(text);
- put(`${target}.opencodex`, { start: at, end, text, value });
- }
- at = end - 1;
- }
- return found;
-}
-
-export function captureJournalConfigFields(content: string, ownership: Ownership): JournalConfigFields {
- const captured: JournalConfigFields = {};
- for (const [key, span] of fields(content)) {
- const recorded = recordedValues(ownership).get(key);
- if (isDeepStrictEqual(span.value, recorded) || key === "model_provider" && span.value === "opencodex"
- || key === "model_providers.opencodex" || key === "profiles.opencodex") captured[key] = span.text;
- }
- // Injection removes the root selection and old inline OCX profiles in URL-only mode.
- for (const key of ["model_provider", "model_providers.opencodex", "profiles.opencodex"]) captured[key] ??= null;
- return captured;
-}
-
-interface Ownership {
- injectedConfigFields?: JournalConfigFields;
- injectedOpenaiBaseUrl?: string | null;
- injectedRealtimeWsBaseUrl?: string | null;
- injectedCatalogPath?: string | null;
- injectedRootWebSearch?: string | null;
- replacedRootWebSearch?: string | null;
- injectedSubagentDefaults?: WrittenSubagentDefaults | null;
-}
-
-function recordedValues(journal: Ownership): Map {
- const values = new Map();
- for (const [key, value] of [
- ["openai_base_url", journal.injectedOpenaiBaseUrl],
- ["experimental_realtime_ws_base_url", journal.injectedRealtimeWsBaseUrl],
- ["model_catalog_json", journal.injectedCatalogPath],
- ["web_search", journal.injectedRootWebSearch],
- ] as const) if (typeof value === "string") values.set(key, value);
- return values;
-}
-
-/** Three-way restore: an edited preference stays; an edited routing field blocks hand-back. */
-export function restoreJournalConfigFields(current: string, original: string, journal: Ownership, recoverOwnedEdits = false): string | null {
- try {
- const defaults = transformManagedSubagentDefaults(current, null, journal.injectedSubagentDefaults);
- if (!defaults.ok) return null;
- current = defaults.content;
- const now = fields(current), before = fields(original);
- if (journal.replacedRootWebSearch) {
- const replaced = fields(journal.replacedRootWebSearch).get("web_search");
- if (!replaced) return null;
- before.set("web_search", replaced);
- }
- const expected = new Map();
- if (journal.injectedConfigFields) {
- for (const [key, text] of Object.entries(journal.injectedConfigFields)) {
- if (text === null) { expected.set(key, undefined); continue; }
- const parsed = fields(text);
- const field = parsed.get(key);
- if (!field) return null;
- expected.set(key, field.value);
- }
- } else {
- // Version-1 journals predate field snapshots. Only their explicit value evidence
- // authorizes removal; a whole-file hash alone never authorizes a guessed URL.
- for (const [key, value] of recordedValues(journal)) expected.set(key, value);
- if (now.get("model_provider")?.value === "opencodex") expected.set("model_provider", "opencodex");
- }
- const { bom, lines } = rootSourceLines(current);
- if (recoverOwnedEdits) {
- // Legacy journals lack field snapshots. An exact structural marker is evidence
- // only for its adjacent owned span; loopback-looking values alone prove nothing.
- for (const [key, span] of now) {
- const marker = lines[span.start - 1];
- if (key !== "web_search" && !expected.has(key) && marker?.structural && isOcxRoutingMarkerLine(marker.text)) expected.set(key, span.value);
- }
- }
- const replacements = new Map();
- const prepend: string[] = [];
- const append: string[] = [];
- const eol = lines.find(line => line.eol)?.eol || "\n";
- for (const key of new Set([...now.keys(), ...expected.keys()])) {
- const present = now.get(key), prior = before.get(key);
- if (isDeepStrictEqual(present?.value, prior?.value)) continue;
- if (!expected.has(key)) {
- if (key === "model" || key.startsWith("agents.") || key === "web_search") continue;
- return null;
- }
- const injected = expected.get(key);
- if (isDeepStrictEqual(injected, prior?.value)) continue; // never owned
- if (!isDeepStrictEqual(present?.value, injected)) {
- if (key === "model" || key.startsWith("agents.") || key === "web_search") continue;
- if (!recoverOwnedEdits) return null;
- }
- const replacement = prior ? prior.text.replace(/\r?\n/g, eol).replace(/(?:\r?\n)?$/, eol) : "";
- if (present) {
- let start = present.start;
- if (start > 0 && lines[start - 1]!.structural && isOcxRoutingMarkerLine(lines[start - 1]!.text)) start--;
- replacements.set(start, { end: present.end, text: replacement });
- } else if (replacement) {
- // A removed root value can safely return before the first table. Missing
- // non-root spans require a containing table, so refuse ambiguous placement.
- if (key.startsWith("agents.")) return null;
- if (key.includes(".")) append.push(replacement);
- else prepend.push(replacement);
- }
- }
- let out = bom + prepend.join("");
- for (let at = 0; at < lines.length;) {
- const replace = replacements.get(at);
- if (replace) { out += replace.text; at = replace.end; }
- else { out += sourceText([lines[at]!]); at++; }
- }
- return append.length ? `${out}${out.endsWith("\n") ? "" : eol}${append.join("")}` : out;
- } catch { return null; }
-}
-
-/** Private, exclusive, durable preimage. Never registered as an uninstall-owned artifact. */
-export function snapshotDisconnectRecovery(home: string, config: string | null, profile: string | null, journal: unknown): string {
- const dir = mkdtempSync(join(home, ".ocx-disconnect-recovery-"));
- chmodSync(dir, 0o700);
- hardenSecretDir(dir, { required: true });
- const path = join(dir, "snapshot.json");
- const fd = openSync(path, "wx", 0o600);
- try {
- // Harden the empty file before placing possibly sensitive profile/config bytes in it.
- hardenSecretPath(path, { required: true });
- writeFileSync(fd, JSON.stringify({ version: 1, config, profile, journal }));
- fsyncSync(fd);
- } finally { closeSync(fd); }
- if (process.platform !== "win32") {
- for (const entry of [dir, home]) {
- const directory = openSync(entry, "r");
- try { fsyncSync(directory); } finally { closeSync(directory); }
- }
- }
- return dir;
-}
diff --git a/src/codex/journal.ts b/src/codex/journal.ts
index 0836cbca561..fb8311a551e 100644
--- a/src/codex/journal.ts
+++ b/src/codex/journal.ts
@@ -10,7 +10,6 @@ import { configWriteLockFailureMessage, withConfigWriteLockHeld } from "./config
import type { LockHandle } from "./config-write-lock";
import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
import { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "./subagent-defaults";
-import { captureJournalConfigFields, restoreJournalConfigFields, snapshotDisconnectRecovery, type JournalConfigFields } from "./journal-config-restore";
/**
* Exported so that anything reasoning ABOUT the journal points at the journal.
@@ -32,7 +31,6 @@ interface Journal {
originalConfig: string;
originalProfile: string | null;
injectedConfigHash?: string;
- injectedConfigFields?: JournalConfigFields;
injectedProfileHash?: string | null;
/**
* The exact root `openai_base_url` this injection wrote, when it wrote one.
@@ -96,7 +94,6 @@ interface Journal {
}
export interface RestoreJournalResult {
- recoveryBackupDir?: string;
/** The shared config write lock was held by another opencodex writer; restore did not run. */
lockBusy?: true;
lockUnsafe?: string;
@@ -250,7 +247,6 @@ export function markJournalInjectedState(
// hashing those newer bytes and restoring the first snapshot would delete those edits.
// Keep the first hash so changed/reinjected configs take the owned-field fallback path.
journal.injectedConfigHash ??= sha256(config) ?? undefined;
- journal.injectedConfigFields = captureJournalConfigFields(config, ownership);
// The profile file is wholly generated by OpenCodex, so its latest exact hash remains safe
// to refresh and lets restore remove the latest generated profile after a port change.
journal.injectedProfileHash = sha256(profile);
@@ -358,7 +354,7 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner
}
export function restoreJournalState(
- options: { heldConfigWriteLock?: LockHandle; preserveConfigEdits?: boolean; recoverConfigForClient?: string; beforeWrite?: () => void } = {},
+ options: { heldConfigWriteLock?: LockHandle } = {},
): RestoreJournalResult {
if (codexHomeIsAbsent(CODEX_HOME)) return {
configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
@@ -372,7 +368,6 @@ export function restoreJournalState(
configChanged: false, profileChanged: false, complete: false, unverified: true, ownershipRefusal: error.reason };
}
beginCodexWriteSection(held);
- options.beforeWrite?.();
const journal = readJournal();
if (!journal) {
return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
@@ -382,12 +377,9 @@ export function restoreJournalState(
const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null;
const comparison = compareJournalState(journal, currentConfig, currentProfile);
const { configUnchanged, profileUnchanged } = comparison;
- const recovery = options.recoverConfigForClient !== undefined;
- if (recovery && (!options.recoverConfigForClient || journal.owner?.kind !== "client"
- || journal.owner.apiKeyId !== options.recoverConfigForClient)) throw new Error("disconnect recovery refused: Codex journal must belong to the connected client key");
// A legacy record or interruption before markJournalInjectedState is not proof that
// later bytes belong to OpenCodex. Keep the whole pair and its recovery evidence intact.
- if ((!recovery && comparison.unverified) || (recovery && !profileUnchanged)) {
+ if (comparison.unverified) {
return {
configRestored: comparison.configAlreadyOriginal,
profileRestored: comparison.profileAlreadyOriginal,
@@ -396,18 +388,10 @@ export function restoreJournalState(
configChanged: !configUnchanged,
profileChanged: !profileUnchanged,
complete: false,
- unverified: comparison.unverified,
+ unverified: true,
};
}
- const fieldRestore = (recovery || options.preserveConfigEdits && !configUnchanged) && currentConfig !== null
- ? restoreJournalConfigFields(currentConfig, comparison.originalConfig, journal, recovery) : null;
- if ((recovery || options.preserveConfigEdits && !configUnchanged) && fieldRestore === null) {
- return { configRestored: false, profileRestored: comparison.profileAlreadyOriginal,
- configRewritten: false, profileRewritten: false, configChanged: true,
- profileChanged: !profileUnchanged, complete: false, unverified: false };
- }
-
// A matching byte hash does not authorize following a newly substituted profile link.
// Refuse the complete pair before restoring config or deleting its recovery journal.
let profileIsSymlink = false;
@@ -424,16 +408,12 @@ export function restoreJournalState(
};
}
assertCodexHomeOwner(CODEX_HOME);
- options.beforeWrite?.();
- const recoveryBackupDir = recovery
- ? snapshotDisconnectRecovery(CODEX_HOME, currentConfig, currentProfile, journal) : undefined;
let configRestored = comparison.configAlreadyOriginal;
let profileRestored = comparison.profileAlreadyOriginal;
let configRewritten = false;
let profileRewritten = false;
- if ((configUnchanged || fieldRestore !== null) && !configRestored) {
- options.beforeWrite?.();
- publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, fieldRestore ?? comparison.originalConfig, undefined, hooks));
+ if (configUnchanged && !configRestored) {
+ publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalConfig, undefined, hooks));
configRestored = true;
configRewritten = true;
}
@@ -465,7 +445,6 @@ export function restoreJournalState(
catch { complete = false; }
}
return {
- ...(recoveryBackupDir ? { recoveryBackupDir } : {}),
configRestored,
profileRestored,
configRewritten,
diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts
index 38a1a021c01..5df3213e410 100644
--- a/src/config/schema/leaf-validators.ts
+++ b/src/config/schema/leaf-validators.ts
@@ -1,5 +1,4 @@
import * as z from "zod/v4";
-import { EXPORT_CLIENT_IDS } from "../../clients/client-ids";
import { accountAutoPausePolicySchema, accountPauseDetailsSchema } from "./account-auto-pause";
import { anthropicAccountPoolSchema } from "./anthropic-account-pool";
import { join } from "node:path";
@@ -958,7 +957,7 @@ export const remoteGuiConfigSchema = z.object({
allowInsecureHttp: z.boolean().optional(),
}).strict();
-const connectedClientIdSchema = z.enum(["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS]);
+const connectedClientIdSchema = z.enum(["codex", "claude"]);
const clientTimestampSchema = z.string().datetime({ offset: true });
const clientTransportSchema = z.enum(["hub", "link"]);
const linkTransportSchema = z.object({
@@ -981,7 +980,7 @@ export const clientConnectionSchema = z.object({
managementTransport: z.enum(["direct", "relay"]),
transport: clientTransportSchema.optional(),
link: linkTransportSchema.optional(),
- selectedClients: z.array(connectedClientIdSchema).superRefine((clients, ctx) => {
+ selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => {
if (new Set(clients).size !== clients.length) {
ctx.addIssue({ code: "custom", message: "must contain unique client ids" });
}
diff --git a/src/integrations/route-intent-catalog.ts b/src/integrations/route-intent-catalog.ts
deleted file mode 100644
index 161f1c5df09..00000000000
--- a/src/integrations/route-intent-catalog.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/** Passive routing facts only. No catalog discovery, persistence, targets, or credentials. */
-import { comboPublicModelId, isValidComboId } from "../combos/identifiers";
-import type { OcxConfig } from "../types";
-
-export const MAX_ROUTE_CATALOG_BYTES = 1024 * 1024;
-export interface RouteCatalogFacts { version: 1; combos: Array<{ publicId: string; comboId: string }> }
-export function parseRouteCatalogFacts(raw: unknown): RouteCatalogFacts | null {
- if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).sort().join(",") !== "combos,version"
- || !("version" in raw) || raw.version !== 1 || !("combos" in raw) || !Array.isArray(raw.combos) || raw.combos.length > 4096) return null;
- const seen = new Set();
- const combos: RouteCatalogFacts["combos"] = [];
- for (const row of raw.combos) {
- if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).sort().join(",") !== "comboId,publicId"
- || typeof row.comboId !== "string" || !isValidComboId(row.comboId) || typeof row.publicId !== "string"
- || !row.publicId || row.publicId.length > 512 || /[\x00-\x1f\x7f]/.test(row.publicId) || seen.has(row.publicId)) return null;
- seen.add(row.publicId); combos.push({ publicId: row.publicId, comboId: row.comboId });
- }
- return { version: 1, combos };
-}
-export function projectRouteCatalogFacts(config: OcxConfig): RouteCatalogFacts {
- const facts = parseRouteCatalogFacts({ version: 1, combos: Object.entries(config.combos ?? {}).map(([comboId, combo]) => ({
- publicId: comboPublicModelId(comboId, combo), comboId,
- })) });
- if (!facts || Buffer.byteLength(JSON.stringify(facts)) > MAX_ROUTE_CATALOG_BYTES) throw new Error("Route catalog facts are invalid or too large");
- return facts;
-}
diff --git a/src/integrations/route-intent-connected.ts b/src/integrations/route-intent-connected.ts
deleted file mode 100644
index e2669673eac..00000000000
--- a/src/integrations/route-intent-connected.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-/** Read-only Home fetches, followed by local materialization under the caller's sync fence. */
-import type { ExportModel } from "../clients/config-export";
-import { loadConfig } from "../config";
-import { withClientLifecycleSync } from "../client/lifecycle-lock";
-import { nativeOpenAiSlugs } from "../codex/catalog/metadata";
-import { COMBO_NAMESPACE } from "../combos/identifiers";
-import type { LocalRouteIntentOptions } from "../client/route-intent-apply";
-import { clearHomeRouteIntentRecord, fetchHomeRouteIntent, homeRouteIntentReceipt, linkBoundFetch, readHomeRouteIntentRecord,
- writeHomeRouteIntentRecord, type LinkHomeConnection, type LinkHomeDeps } from "../client/link-home";
-import { readBoundedResponseBytes } from "../lib/bounded-body";
-import { ROUTE_CLIENT_IDS, type HomeRouteIntentDocument } from "../link/route-intent";
-import type { OcxClientConnectionConfig } from "../types";
-import type { RouteIntentReceipt } from "./route-intent-contract";
-import { effectiveRouteIntent, readLocalRouteIntentOff, readRouteIntentReceipt, writeRouteIntentReceipt } from "./route-intent-state";
-import { resolveInitialRouteIntent, initialRouteOutcomeMetadata } from "./route-intent-initial";
-import { LINK_ROUTE_CATALOG_PATH } from "../link/management-routes";
-import { MAX_ROUTE_CATALOG_BYTES, parseRouteCatalogFacts } from "./route-intent-catalog";
-
-export interface PreparedConnectedRouteIntent { document: HomeRouteIntentDocument; fetchedAt: string; stale: boolean; localOnly?: true }
-
-/** Provisioning preview; connect records its enabled-only choices after enrollment. */
-export function initialConnectRouteIntent(
- prepared: PreparedConnectedRouteIntent | undefined, selected: OcxClientConnectionConfig["selectedClients"], explicit = false,
-): PreparedConnectedRouteIntent | undefined {
- if (!prepared || !explicit) return prepared;
- const document = structuredClone(prepared.document);
- for (const client of ROUTE_CLIENT_IDS) {
- const enabled = selected.includes(client);
- if (client === "codex" || client === "claude" || enabled) {
- document.clients[client] = { ...document.clients[client], enabled };
- document.sources[client] = "device";
- }
- }
- return { ...prepared, document };
-}
-
-export async function prepareConnectedRouteIntent(
- connection: LinkHomeConnection, token: string, deps?: LinkHomeDeps, beforeWrite?: () => void,
-): Promise {
- if (connection.transport !== "link" || !connection.link) return undefined;
- const fetched = await fetchHomeRouteIntent(connection, token, deps);
- if (fetched.kind === "fresh") return { document: resolveInitialRouteIntent(connection.link.linkId, fetched.document, beforeWrite), fetchedAt: new Date().toISOString(), stale: false };
- const previous = fetched.kind === "failed" ? readHomeRouteIntentRecord(connection.link.linkId) : null;
- return previous ? { document: resolveInitialRouteIntent(connection.link.linkId, previous.document, beforeWrite), fetchedAt: previous.fetchedAt, stale: true } : undefined;
-}
-
-export function connectedRouteSelectedClients(prepared: PreparedConnectedRouteIntent | undefined, fallback: OcxClientConnectionConfig["selectedClients"]) {
- const off = new Set(readLocalRouteIntentOff());
- return prepared ? effectiveRouteIntent(prepared.document).selectedClients : fallback.filter(id => !off.has(id));
-}
-
-/** Read the exact /v1/models rows this Child relays, preserving ids byte-for-byte. */
-export async function loadConnectedRouteModels(connection: LinkHomeConnection, token: string, deps: LinkHomeDeps = {}): Promise {
- if (!connection.link) throw new Error("A Link connection is required for the local served roster");
- const fetchImpl = deps.fetchImpl ?? linkBoundFetch(connection, token);
- const response = await fetchImpl(`http://127.0.0.1:${connection.link.tunnelPort}/v1/models`, {
- method: "GET", headers: { authorization: `Bearer ${token}` }, redirect: "manual", signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
- });
- if (!response.ok) { await response.body?.cancel(); throw new Error("The Child served model roster is unavailable"); }
- const body = await readBoundedResponseBytes(response, { maxBytes: 8 * 1024 * 1024, signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000) });
- if (body.oversized) throw new Error("The Child served model roster is too large");
- const raw: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body.bytes));
- if (!raw || typeof raw !== "object" || !("data" in raw) || !Array.isArray(raw.data)) throw new Error("Invalid Child served model roster");
- const seen = new Set();
- const nativeIds = new Set(nativeOpenAiSlugs());
- const comboIdentity = new Map();
- if (raw.data.some(row => row && typeof row === "object" && row.is_combo === true)) {
- // is_combo describes the inbound adapter in owned_by. This separate passive projection
- // contains only public selectors and canonical combo keys, never model discovery.
- const response = await fetchImpl(`http://127.0.0.1:${connection.link.tunnelPort}${LINK_ROUTE_CATALOG_PATH}`, {
- method: "GET", headers: { authorization: `Bearer ${token}` }, redirect: "manual", signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
- });
- if (!response.ok) { await response.body?.cancel(); throw new Error("Combo route identity is unavailable"); }
- const body = await readBoundedResponseBytes(response, { maxBytes: MAX_ROUTE_CATALOG_BYTES, signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000) });
- if (body.oversized) throw new Error("Combo metadata is too large");
- const facts = parseRouteCatalogFacts(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body.bytes)));
- if (!facts) throw new Error("Invalid combo route metadata");
- for (const row of facts.combos) comboIdentity.set(row.publicId, row.comboId);
- }
- return raw.data.map((row: unknown): ExportModel => {
- if (!row || typeof row !== "object" || !("id" in row) || typeof row.id !== "string" || !row.id || seen.has(row.id)) {
- throw new Error("Invalid or duplicate Child served model identifier");
- }
- seen.add(row.id);
- const owner = "owned_by" in row ? row.owned_by : undefined;
- if ("is_combo" in row && row.is_combo === true) {
- const id = comboIdentity.get(row.id);
- if (!id) throw new Error("The served combo has no verified route identity");
- return { namespaced: row.id, provider: COMBO_NAMESPACE, id };
- }
- const canonical = "native_id" in row ? row.native_id : row.id;
- if (typeof canonical !== "string" || !canonical || canonical !== canonical.trim() || canonical.length > 4096 || /[\x00-\x1f\x7f]/.test(canonical)) {
- throw new Error("Invalid canonical served model identity");
- }
- const slash = canonical.indexOf("/");
- const native = slash < 0 && owner !== COMBO_NAMESPACE && (nativeIds.has(canonical) || owner === "openai");
- if (slash < 0 && !native) throw new Error("The served alias has no verified canonical route identity");
- if (!native && (slash === 0 || slash === canonical.length - 1)) throw new Error("Invalid canonical routed model identity");
- return { namespaced: row.id, provider: native ? "openai" : canonical.slice(0, slash),
- id: native ? canonical : canonical.slice(slash + 1), ...(native ? { native: true } : {}) };
- });
-}
-
-export async function applyConnectedRouteIntent(
- connection: OcxClientConnectionConfig, token: string, prepared: PreparedConnectedRouteIntent | undefined,
- options: LocalRouteIntentOptions, deps: LinkHomeDeps = {},
-): Promise {
- if (!connection.link) return undefined;
- const localOff = readLocalRouteIntentOff(options.statePath);
- if (!prepared && localOff.length > 0) {
- const previous = readRouteIntentReceipt(options.statePath);
- const document: HomeRouteIntentDocument = previous?.document ?? { version: 1, home: { name: "Home", version: "unavailable" },
- clients: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, { enabled: connection.selectedClients.includes(id) }])) as HomeRouteIntentDocument["clients"],
- sources: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, "builtin"])) as HomeRouteIntentDocument["sources"] };
- prepared = { document, stale: true, fetchedAt: new Date().toISOString(), localOnly: true };
- options = { ...options, onlyClients: localOff };
- }
- if (!prepared) return undefined;
- const { materializeLocalRouteIntent } = await import("../client/route-intent-apply");
- const intent = prepared;
- const linkId = connection.link.linkId;
- const guard = options.beforeWrite ?? (() => {});
- const previousModels = readRouteIntentReceipt(options.statePath)?.models ?? [];
- const local = loadConfig();
- const config = { ...local, hostname: "127.0.0.1", unauthenticatedLoopbackListener: undefined };
- const previousHomeRecord = readHomeRouteIntentRecord(linkId);
- const { createIntegrationStateStore } = await import("./store");
- const { captureFileIntentOriginals } = await import("../client/route-intent-file-originals");
- const store = options.store ?? createIntegrationStateStore();
- withClientLifecycleSync(() => {
- guard();
- // Write-ahead, before any client changes: which file integrations were standalone.
- captureFileIntentOriginals(linkId, readRouteIntentReceipt(options.statePath)?.linkId === undefined, store.readRecordsStrict());
- // A failed materialization must not leave the separately reportable Home proof current.
- clearHomeRouteIntentRecord();
- }, options.lifecycleLockDeps);
- const persist = (receipt: RouteIntentReceipt) => {
- receipt.outcomes = receipt.outcomes.map(initialRouteOutcomeMetadata(linkId, intent.document));
- withClientLifecycleSync(() => {
- guard();
- writeRouteIntentReceipt(receipt, options.statePath);
- if (intent.localOnly) { clearHomeRouteIntentRecord(); return; }
- const record = homeRouteIntentReceipt(linkId, intent.document, intent.fetchedAt, receipt.appliedAt, receipt.outcomes, previousHomeRecord);
- try { writeHomeRouteIntentRecord(record); }
- catch (error) { clearHomeRouteIntentRecord(); throw error; }
- }, options.lifecycleLockDeps);
- };
- // Disable and opt-out need no catalog. Their proven outcomes survive an offline Home;
- // every enabled client stays stale until its local served model roster is available.
- const effective = effectiveRouteIntent(intent.document, options.statePath);
- const off = ROUTE_CLIENT_IDS.filter(id => !effective.clients[id].enabled && (!options.onlyClients || options.onlyClients.includes(id)));
- const receipt = await materializeLocalRouteIntent(config, intent.document, previousModels, { ...options, onlyClients: off });
- persist(receipt);
- if (intent.localOnly) { receipt.catalogPending = true; persist(receipt); return receipt; }
- if (off.length === ROUTE_CLIENT_IDS.length) return receipt;
- let models: ExportModel[];
- try { models = await loadConnectedRouteModels(connection, token, deps); }
- catch {
- guard();
- receipt.catalogPending = true;
- persist(receipt);
- return receipt;
- }
- const applied = await materializeLocalRouteIntent(config, intent.document, models, options);
- persist(applied);
- return applied;
-}
diff --git a/src/integrations/route-intent-contract.ts b/src/integrations/route-intent-contract.ts
deleted file mode 100644
index 1c56a98bef2..00000000000
--- a/src/integrations/route-intent-contract.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { ExportModel } from "../clients/config-export";
-import type { HomeRouteIntentDocument, RouteClientId, RouteClientIntent, RouteClientOutcome } from "../link/route-intent";
-import type { OcxConfig } from "../types";
-import type { IntegrationStateStore } from "./store";
-
-export type { RouteClientOutcome } from "../link/route-intent";
-
-export interface RouteIntentReceipt {
- version: 1;
- linkId?: string;
- /** Last local served roster, retained for offline owned-conflict recovery. No credentials. */
- models?: readonly ExportModel[];
- document: HomeRouteIntentDocument;
- clients: Record;
- sources: Record;
- selectedClients: RouteClientId[];
- outcomes: RouteClientOutcome[];
- /** Null if any client failed, conflicts, or is not installed. Never a partial success. */
- appliedRouteIntentDigest: string | null;
- appliedAt: string;
- /** Off intents are proved, while enabled clients await the local served model roster. */
- catalogPending?: true;
-}
-
-export interface RouteIntentApplyInput {
- config: OcxConfig;
- linkId?: string;
- document: HomeRouteIntentDocument;
- /** The local served roster, never Home-generated client contributions. */
- models: readonly ExportModel[];
- home?: string;
- env?: NodeJS.ProcessEnv;
- store?: IntegrationStateStore;
- /** Isolates local opt-outs and receipts; defaults to the local config directory. */
- statePath?: string;
- /** Home passes an empty list; undefined reads this Child's persistent local opt-outs. */
- localOff?: readonly RouteClientId[];
- overwriteConflict?: boolean;
- /** When a client toggle grants takeover, other clients retain ordinary conflict checks. */
- overwriteClient?: RouteClientId;
- /** Rechecks connection/runtime ownership immediately before each writer commits. */
- beforeWrite?: () => void;
- /** Reconcile only explicit local opt-outs when no Home document is available. */
- onlyClients?: readonly RouteClientId[];
- /** Clients skipped by onlyClients keep the previous receipt outcome for the same client intent. */
- retainSkippedOutcomes?: boolean;
- /** Explicit disconnect recovery only: snapshot an owned conflicting block, then remove it. */
- recoverConflicts?: boolean;
- /** Lifecycle exclusion around async file writers; native adapters manage their own lock order. */
- withFileWrites?: (work: () => Promise) => Promise;
- withReceiptWrite?: (write: () => void) => void;
- now?: () => Date;
- /** Native adapters retain their existing ownership/journal implementations. */
- applyNative?: (client: "codex" | "claude" | "claude-desktop" | "grok", intent: RouteClientIntent) => Promise;
-}
diff --git a/src/integrations/route-intent-disconnect.ts b/src/integrations/route-intent-disconnect.ts
deleted file mode 100644
index 2ec9d6e288d..00000000000
--- a/src/integrations/route-intent-disconnect.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/** File clients drain before disconnect removes its connection and service credential. */
-import { loadConfig } from "../config";
-import { lstatSync } from "node:fs";
-import { readClientConnectionState, sameClientConnectionOwner } from "../client/state";
-import { withClientLifecycle, withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
-import { readDesktopDisconnectReceipt, type DesktopDisconnectReceipt } from "../claude/desktop-remote-store";
-import { readServiceApiTokenState } from "../lib/service-secrets";
-import { readLocalRouteIntentOff, readRouteIntentReceiptStrict } from "./route-intent-state";
-import { homeRouteIntentPath } from "../client/link-home";
-import type { OcxClientConnectionConfig } from "../types";
-
-/** Droid's empty-roster contribution has no selectors, so its generic classifier cannot prove absence. */
-async function droidRoutingAbsent(port: number): Promise {
- const { INTEGRATION_CLIENTS } = await import("./registry");
- const { defaultIntegrationIO, loadTarget } = await import("./config-io");
- const { createIntegrationStateStore } = await import("./store");
- const target = loadTarget(defaultIntegrationIO(createIntegrationStateStore()), INTEGRATION_CLIENTS.droid.configPath());
- if (!target.ok) return false;
- if (target.before === null) return true;
- try {
- const value: unknown = JSON.parse(target.before);
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
- const rows = (value as Record).customModels;
- if (rows === undefined) return true;
- if (!Array.isArray(rows)) return false;
- return rows.every(row => {
- if (!row || typeof row !== "object" || Array.isArray(row)) return false;
- if (typeof row.displayName === "string" && row.displayName.startsWith("OpenCodex:")) return false;
- if (typeof row.baseUrl !== "string") return false;
- const url = new URL(row.baseUrl);
- return !(["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.port === String(port));
- });
- } catch { return false; }
-}
-
-export async function cleanupConnectedRouteIntent(
- connection: OcxClientConnectionConfig, expected: DesktopDisconnectReceipt, lifecycleLockDeps?: ClientLifecycleLockDeps, recoverConflicts = false,
-): Promise {
- const receipt = readRouteIntentReceiptStrict();
- if (!receipt) {
- let homeReceiptPresent = false;
- try { lstatSync(homeRouteIntentPath()); homeReceiptPresent = true; }
- catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
- if (connection.link) {
- const { createIntegrationStateStore } = await import("./store");
- const { isIntegrationClientId } = await import("./registry");
- const { readIntegrationState } = await import("./state");
- const { listAsideProfileStores } = await import("./aside-profile-context");
- const store = createIntegrationStateStore();
- const owned = Object.keys(store.readRecordsStrict()).length > 0
- || listAsideProfileStores(store).some(child => Object.keys(child.store.readRecordsStrict()).length > 0);
- const config = loadConfig();
- // Older Homes and --no-sync may never invoke the controller. Only disk absence,
- // not selectedClients alone, proves those file clients have nothing to drain.
- const unproved = connection.selectedClients.some(id => id !== "codex" && id !== "claude"
- && (!isIntegrationClientId(id) || readIntegrationState({ clientId: id, config, port: config.port, models: [], store }).state !== "absent"));
- const droidUnproved = connection.selectedClients.includes("droid") && !await droidRoutingAbsent(config.port);
- if (homeReceiptPresent || owned || unproved || droidUnproved) {
- throw new Error("Route intent cleanup receipt is missing; preserve the connection and repair its route ownership evidence before disconnect");
- }
- }
- return;
- }
- // A valid unbound receipt is standalone Home state, not evidence of a Child apply.
- if (receipt.linkId === undefined) return;
- if (!connection.link || receipt.linkId !== connection.link.linkId) throw new Error("Route intent cleanup receipt belongs to another context; preserve it before disconnect");
- const guard = () => {
- const pending = readDesktopDisconnectReceipt();
- const current = readClientConnectionState();
- const token = readServiceApiTokenState();
- if (pending.kind !== "valid" || JSON.stringify(pending.value) !== JSON.stringify(expected)
- || current.kind !== "connected" || !sameClientConnectionOwner(current.value, connection)
- || current.value.pendingOperation || token.kind !== "present" || token.fingerprint !== connection.tokenFingerprint) {
- throw new Error("client_disconnect_owner_changed");
- }
- };
- const local = loadConfig();
- const { cleanupRouteIntent } = await import("./route-intent");
- const { stripGrokConfig } = await import("../grok/inject");
- const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
- const config = { ...local, hostname: "127.0.0.1", unauthenticatedLoopbackListener: undefined };
- // Integrations the Child owned before this link come back instead of being removed; a local
- // opt-out is the person's own later choice and wins.
- const { readFileIntentOriginals } = await import("../client/route-intent-file-originals");
- const originals = readFileIntentOriginals();
- const optedOut = new Set(readLocalRouteIntentOff());
- const standalone = originals?.linkId === connection.link.linkId
- ? Object.fromEntries(Object.entries(originals.clients).filter(([client]) => !optedOut.has(client))) : {};
- const outcomes = await cleanupRouteIntent({ config, linkId: connection.link.linkId, document: receipt.document, models: receipt.models ?? [], beforeWrite: guard, recoverConflicts,
- withReceiptWrite: write => withClientLifecycleSync(() => { guard(); write(); }, lifecycleLockDeps),
- withFileWrites: work => withClientLifecycle(work, lifecycleLockDeps) }, standalone);
- if (outcomes.some(row => row.outcome !== "current")) throw new Error("Route intent cleanup was partial; ownership and disconnect receipts retained. Repair the conflicting client and rerun ocx disconnect.");
- withClientLifecycleSync(() => {
- guard();
- const grok = stripGrokConfig();
- if (!grok.ok) throw new Error("Grok route cleanup was partial; rerun ocx disconnect after repair");
- const config = loadConfig();
- const agents = injectClaudeAgentDefs({ ...config, claudeCode: { ...config.claudeCode, enabled: false } }, {});
- if (agents === null) throw new Error("Claude route cleanup was partial; rerun ocx disconnect after repair");
- guard();
- }, lifecycleLockDeps);
-}
diff --git a/src/integrations/route-intent-initial.ts b/src/integrations/route-intent-initial.ts
deleted file mode 100644
index 73cd8030a07..00000000000
--- a/src/integrations/route-intent-initial.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/** Provisioning choices are local enabled-only overrides, never writes to Home policy. */
-import { mkdirSync } from "node:fs";
-import { join } from "node:path";
-import { atomicWriteFileNoFollow } from "../config/atomic-write";
-import { readBoundedRegularFileSync } from "../lib/bounded-file-read";
-import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
-import { isRouteClientId, ROUTE_CLIENT_IDS, type HomeRouteIntentDocument, type RouteClientId, type RouteClientOutcome } from "../link/route-intent";
-import { routeIntentStatePath } from "./route-intent-state";
-
-type InitialSelection = Partial>;
-function recordPath(linkId: string, root: string): string {
- if (!/^lnk_[0-9a-f]{16}$/.test(linkId)) throw new Error("Invalid initial route intent link identity");
- return join(root, `initial-${linkId}.json`);
-}
-export function readInitialRouteIntent(linkId: string, root = routeIntentStatePath()): InitialSelection {
- let raw: unknown;
- try { raw = JSON.parse(readBoundedRegularFileSync(recordPath(linkId, root), 64 * 1024).toString("utf8")); }
- catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error("Initial route selection is unreadable"); }
- if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("version" in raw) || raw.version !== 1
- || !("linkId" in raw) || raw.linkId !== linkId || !("clients" in raw) || !raw.clients || typeof raw.clients !== "object" || Array.isArray(raw.clients)) {
- throw new Error("Invalid initial route selection");
- }
- const clients: InitialSelection = {};
- for (const [id, entry] of Object.entries(raw.clients)) {
- if (!isRouteClientId(id) || !entry || typeof entry !== "object" || Array.isArray(entry)
- || Object.keys(entry).sort().join(",") !== "enabled,revision" || !("enabled" in entry) || typeof entry.enabled !== "boolean"
- || !("revision" in entry) || !Number.isSafeInteger(entry.revision) || Number(entry.revision) < 0) throw new Error("Invalid initial route selection client");
- clients[id] = { enabled: entry.enabled, revision: Number(entry.revision) };
- }
- return clients;
-}
-function writeInitial(linkId: string, clients: InitialSelection, root: string): void {
- assertNotRealHomeUnderTest(root); mkdirSync(root, { recursive: true, mode: 0o700 });
- atomicWriteFileNoFollow(recordPath(linkId, root), JSON.stringify({ version: 1, linkId, clients }));
-}
-export function recordInitialRouteIntent(linkId: string, document: HomeRouteIntentDocument | undefined, selected: readonly RouteClientId[], root = routeIntentStatePath()): void {
- const clients: InitialSelection = {};
- for (const id of ROUTE_CLIENT_IDS) if (id === "codex" || id === "claude" || selected.includes(id)) {
- clients[id] = { enabled: selected.includes(id), revision: document?.clientRevisions?.[id] ?? 0 };
- }
- writeInitial(linkId, clients, root);
-}
-export function resolveInitialRouteIntent(linkId: string, document: HomeRouteIntentDocument, beforeWrite: () => void = () => {}, root = routeIntentStatePath()): HomeRouteIntentDocument {
- const clients = readInitialRouteIntent(linkId, root);
- const next = structuredClone(document);
- let removed = false;
- for (const id of ROUTE_CLIENT_IDS) {
- const initial = clients[id]; if (!initial) continue;
- if ((document.clientRevisions?.[id] ?? 0) !== initial.revision) { delete clients[id]; removed = true; continue; }
- next.clients[id] = { ...next.clients[id], enabled: initial.enabled };
- next.sources[id] = "device";
- }
- if (removed) { beforeWrite(); writeInitial(linkId, clients, root); }
- return next;
-}
-export function initialRouteOutcomeMetadata(linkId: string, document: HomeRouteIntentDocument, root = routeIntentStatePath()) {
- const initial = readInitialRouteIntent(linkId, root);
- return (row: RouteClientOutcome): RouteClientOutcome => {
- const entry = initial[row.client];
- return entry && entry.revision === (document.clientRevisions?.[row.client] ?? 0)
- ? { ...row, initialIntent: { enabled: entry.enabled }, initialRevision: entry.revision } : row;
- };
-}
-export function clearInitialRouteIntent(linkId: string, root = routeIntentStatePath()): void { writeInitial(linkId, {}, root); }
diff --git a/src/integrations/route-intent-state.ts b/src/integrations/route-intent-state.ts
deleted file mode 100644
index 83cae967ba6..00000000000
--- a/src/integrations/route-intent-state.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { mkdirSync, unlinkSync } from "node:fs";
-import { join } from "node:path";
-import { atomicWriteFileNoFollow } from "../config/atomic-write";
-import { getConfigDir } from "../config/paths";
-import { readBoundedRegularFileSync } from "../lib/bounded-file-read";
-import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
-import { ROUTE_CLIENT_IDS, isRouteClientId, parseHomeRouteIntentDocument, parseRouteClientIntent, type HomeRouteIntentDocument, type RouteClientId } from "../link/route-intent";
-import type { RouteIntentReceipt } from "./route-intent-contract";
-
-export function routeIntentStatePath(): string { return join(getConfigDir(), "route-intent"); }
-
-function readJson(path: string, maxBytes = 16 * 1024 * 1024): unknown {
- try { return JSON.parse(readBoundedRegularFileSync(path, maxBytes).toString("utf8")); }
- catch (error) {
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
- throw new Error("Local route intent state is unreadable; preserve it for recovery");
- }
-}
-
-export function readLocalRouteIntentOff(statePath = routeIntentStatePath()): RouteClientId[] {
- const raw = readJson(join(statePath, "local-off.json"), 64 * 1024);
- if (raw === undefined) return [];
- if (!raw || typeof raw !== "object" || !("version" in raw) || raw.version !== 1 || !("clients" in raw)
- || !Array.isArray(raw.clients) || raw.clients.some(id => !ROUTE_CLIENT_IDS.includes(id))
- || new Set(raw.clients).size !== raw.clients.length) throw new Error("Invalid local route intent opt-outs");
- return raw.clients;
-}
-
-export function effectiveRouteIntent(document: HomeRouteIntentDocument, statePath = routeIntentStatePath(), localOff?: readonly RouteClientId[]) {
- const clients = structuredClone(document.clients);
- const sources: RouteIntentReceipt["sources"] = { ...document.sources };
- for (const id of localOff ?? readLocalRouteIntentOff(statePath)) {
- clients[id] = { ...clients[id], enabled: false };
- sources[id] = "local";
- }
- return { clients, sources, selectedClients: ROUTE_CLIENT_IDS.filter(id => clients[id].enabled) };
-}
-
-export function writeRouteIntentReceipt(receipt: RouteIntentReceipt, statePath = routeIntentStatePath()): void {
- assertNotRealHomeUnderTest(statePath);
- mkdirSync(statePath, { recursive: true, mode: 0o700 });
- const body = JSON.stringify(receipt);
- if (Buffer.byteLength(body) > 16 * 1024 * 1024) throw new Error("Local route intent receipt is too large");
- atomicWriteFileNoFollow(join(statePath, "receipt.json"), body);
-}
-
-export function readRouteIntentReceipt(statePath = routeIntentStatePath()): RouteIntentReceipt | null {
- try { return readRouteIntentReceiptStrict(statePath); } catch { return null; }
-}
-
-/** Cleanup must distinguish absence from corrupt, unsafe, or incomplete ownership evidence. */
-export function readRouteIntentReceiptStrict(statePath = routeIntentStatePath()): RouteIntentReceipt | null {
- const raw = readJson(join(statePath, "receipt.json")) as RouteIntentReceipt | undefined;
- if (raw === undefined) return null;
- if (!raw || raw.version !== 1 || !parseHomeRouteIntentDocument(raw.document, { allowNative: true }) || !Array.isArray(raw.outcomes)
- || raw.outcomes.length !== ROUTE_CLIENT_IDS.length || new Set(raw.outcomes.map(row => row?.client)).size !== ROUTE_CLIENT_IDS.length
- || !raw.clients || !raw.sources || !Array.isArray(raw.selectedClients) || new Set(raw.selectedClients).size !== raw.selectedClients.length
- || raw.selectedClients.some(id => !isRouteClientId(id)) || typeof raw.appliedAt !== "string" || !Number.isFinite(Date.parse(raw.appliedAt))
- || (raw.linkId !== undefined && !/^lnk_[0-9a-f]{16}$/.test(raw.linkId))
- || (raw.models !== undefined && (!Array.isArray(raw.models) || raw.models.some(row => !row || typeof row.namespaced !== "string" || typeof row.provider !== "string" || typeof row.id !== "string")))
- || !(raw.appliedRouteIntentDigest === null || (typeof raw.appliedRouteIntentDigest === "string" && /^[0-9a-f]{64}$/.test(raw.appliedRouteIntentDigest)))
- || raw.outcomes.some(row => !row || !isRouteClientId(row.client) || !["current", "stale", "not_installed", "conflict", "failed"].includes(row.outcome))
- || ROUTE_CLIENT_IDS.some(id => !parseRouteClientIntent(raw.clients[id], id) || !["builtin", "default", "device", "native", "local"].includes(raw.sources[id])
- || raw.selectedClients.includes(id) !== raw.clients[id].enabled)) throw new Error("Route intent receipt is malformed; preserve it and repair before disconnect");
- return raw;
-}
-
-export function clearRouteIntentReceipt(statePath = routeIntentStatePath()): void {
- assertNotRealHomeUnderTest(statePath);
- try { unlinkSync(join(statePath, "receipt.json")); }
- catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
-}
-
-/** true opts out; false drops the override and inherits the Home again. Never enables locally. */
-export function setLocalRouteIntentOff(client: RouteClientId, off: boolean, statePath = routeIntentStatePath()): void {
- if (!ROUTE_CLIENT_IDS.includes(client) || typeof off !== "boolean") throw new Error("Invalid local route intent override");
- const clients = new Set(readLocalRouteIntentOff(statePath));
- if (off) clients.add(client); else clients.delete(client);
- assertNotRealHomeUnderTest(statePath);
- mkdirSync(statePath, { recursive: true, mode: 0o700 });
- // Invalidate before publishing the override: failure cannot leave an old success claim.
- const receipt = readRouteIntentReceiptStrict(statePath);
- if (receipt) writeRouteIntentReceipt({ ...receipt, appliedRouteIntentDigest: null,
- outcomes: receipt.outcomes.map(row => row.client === client ? { ...row, outcome: "stale" } : row) }, statePath);
- atomicWriteFileNoFollow(join(statePath, "local-off.json"), JSON.stringify({ version: 1, clients: [...clients].sort() }));
-}
diff --git a/src/integrations/route-intent-tick.ts b/src/integrations/route-intent-tick.ts
deleted file mode 100644
index 3469351c00f..00000000000
--- a/src/integrations/route-intent-tick.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { OcxClientConnectionConfig } from "../types";
-
-export interface RouteIntentTickDeps {
- sync?: () => Promise;
- onApplied?: () => void | Promise;
- setTimer?: (callback: () => void, ms: number) => unknown;
- clearTimer?: (timer: unknown) => void;
-}
-export interface RouteIntentTick { stop(): void; requestSync(): Promise }
-
-/** Periodic and Home-requested applies share one queue and the existing sync ownership fences. */
-export function startRouteIntentTick(connection: OcxClientConnectionConfig, deps: RouteIntentTickDeps = {}): RouteIntentTick {
- if (connection.transport !== "link" || !connection.link) return { stop() {}, requestSync: async () => { throw new Error("not_a_link_child"); } };
- const linkId = connection.link.linkId;
- const sync = deps.sync ?? (async () => {
- const { syncConnectedClient } = await import("../client/connect");
- return syncConnectedClient({ expect: { kind: "link", linkId } });
- });
- const set = deps.setTimer ?? ((callback, ms) => { const timer = setTimeout(callback, ms); timer.unref(); return timer; });
- const clear = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType));
- let stopped = false, running = false, queued = false;
- let timer: unknown;
- const waiters: Array<{ resolve: (result: unknown) => void; reject: (error: unknown) => void }> = [];
- const schedule = (ms: number) => {
- if (!stopped) timer = set(() => { timer = undefined; queued = true; void drain(); }, ms);
- };
- const drain = async () => {
- if (stopped || running) return;
- running = true;
- let delay = 60_000;
- try {
- do {
- queued = false;
- const batch = waiters.splice(0);
- try {
- const result = await sync();
- await deps.onApplied?.();
- for (const waiter of batch) waiter.resolve(result);
- delay = 60_000;
- } catch (error) {
- for (const waiter of batch) waiter.reject(error);
- delay = 5_000;
- }
- } while (queued && !stopped);
- } finally { running = false; schedule(delay); }
- };
- schedule(2_000);
- return {
- requestSync() {
- if (stopped) return Promise.reject(new Error("client_runtime_stopping"));
- if (timer !== undefined) clear(timer);
- timer = undefined;
- queued = true;
- const result = new Promise((resolve, reject) => waiters.push({ resolve, reject }));
- void drain();
- return result;
- },
- stop() {
- stopped = true; queued = false;
- if (timer !== undefined) clear(timer);
- timer = undefined;
- for (const waiter of waiters.splice(0)) waiter.reject(new Error("client_runtime_stopping"));
- },
- };
-}
diff --git a/src/integrations/route-intent.ts b/src/integrations/route-intent.ts
deleted file mode 100644
index 5f61eecc898..00000000000
--- a/src/integrations/route-intent.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-/** Device-local intent controller. All client mutations go through the existing writers. */
-import { existsSync } from "node:fs";
-import { homedir } from "node:os";
-import { join } from "node:path";
-import { routeClientIntentDigest, routeIntentDigest, ROUTE_CLIENT_IDS, type RouteClientId, type RouteClientIntent } from "../link/route-intent";
-import { isIntegrationClientId, type IntegrationClientId } from "./registry";
-import { readIntegrationState } from "./state";
-import { createIntegrationStateStore } from "./store";
-import { applyIntegrationCoordinated, disableIntegrationCoordinated, overwriteIntegrationCoordinated, refreshIntegrationCoordinated,
- type IntegrationWriteInput, type WriteOutcome } from "./writer";
-import { createAsideProfileContext, asideProfileScope, asideWriteInput, asideRootStore, listAsideProfileStores } from "./aside-profile-context";
-import { asideHomeDir } from "../clients/config-export";
-import { readBoundedRegularFileSync } from "../lib/bounded-file-read";
-import type { RouteClientOutcome, RouteIntentApplyInput, RouteIntentReceipt } from "./route-intent-contract";
-import { effectiveRouteIntent, readRouteIntentReceipt, routeIntentStatePath, writeRouteIntentReceipt } from "./route-intent-state";
-export { clearRouteIntentReceipt, effectiveRouteIntent, readLocalRouteIntentOff, readRouteIntentReceipt, readRouteIntentReceiptStrict, routeIntentStatePath, setLocalRouteIntentOff } from "./route-intent-state";
-export type { RouteClientOutcome, RouteIntentApplyInput, RouteIntentReceipt } from "./route-intent-contract";
-
-const flights = new Set();
-
-function project(client: RouteClientId, result: WriteOutcome, target: string): RouteClientOutcome {
- return { client, target, outcome: result.ok ? "current" : result.reason === "not_installed" ? "not_installed"
- : result.state === "conflict" ? "conflict" : "failed", message: result.message };
-}
-
-async function writeClient(input: RouteIntentApplyInput, bound: IntegrationWriteInput, intent: RouteClientIntent): Promise {
- const state = readIntegrationState(bound);
- // An off intent never adopts an unowned block, including foreign blocks.
- if (!intent.enabled && !bound.store?.readRecords()[bound.clientId]) {
- return { client: bound.clientId, outcome: "current", target: state.configPath };
- }
- if (!intent.enabled && input.recoverConflicts && state.state === "conflict") {
- // A durable record is mandatory above; this cannot adopt an unowned foreign block.
- // Overwrite snapshots the edited preimage through the existing private journal. Unsafe
- // paths, blocked containers and unrepresentable contributions retain their ordinary refusal.
- const recovered = await overwriteIntegrationCoordinated(bound, { guard: () => { input.beforeWrite?.(); return null; } });
- if (!recovered.ok) return project(bound.clientId, recovered, state.configPath);
- }
- const operation = !intent.enabled ? disableIntegrationCoordinated
- : input.overwriteConflict ? overwriteIntegrationCoordinated
- : state.state === "absent" ? applyIntegrationCoordinated : refreshIntegrationCoordinated;
- const result = await operation(bound, { guard: () => { input.beforeWrite?.(); return null; } });
- // A refresh of a deleted owned block returns absent. Intent is still on, so that is stale.
- const outcome = project(bound.clientId, result, state.configPath);
- if (intent.enabled && result.ok && result.state === "absent") outcome.outcome = "stale";
- return outcome;
-}
-
-function manifestText(root: string): string {
- return readBoundedRegularFileSync(join(root, "accounts.json"), 4 * 1024 * 1024).toString("utf8");
-}
-
-async function applyAside(input: RouteIntentApplyInput, intent: RouteClientIntent): Promise {
- const root = asideHomeDir(input.env ?? process.env, input.home ?? homedir());
- const ownershipInput = { ...input, port: input.config.port };
- const rootStore = asideRootStore(ownershipInput);
- const legacy = rootStore.readRecordsStrict().aside;
- const owners = listAsideProfileStores(rootStore).flatMap(child => {
- const records = child.store.readRecordsStrict();
- if (Object.keys(records).some(id => id !== "aside")) throw new Error("Aside profile storage names another client");
- return records.aside ? [{ profileId: child.profileId, record: records.aside }] : [];
- });
- const owned = Boolean(legacy) || owners.length > 0;
- // No owned route means off is already proved, even without a usable account manifest.
- if (!intent.enabled && !owned) return { client: "aside", outcome: "current" };
- if (!existsSync(root)) {
- if (owned) throw new Error("Aside installation is missing with retained route ownership");
- return { client: "aside", outcome: "not_installed" };
- }
- const manifest = manifestText(root);
- const ctx = createAsideProfileContext(ownershipInput);
- // A removed manifest entry is not evidence that its owned file was cleaned up.
- if ((legacy && !ctx.profiles.some(profile => profile.configPath === legacy.configPath))
- || owners.some(owner => !ctx.profiles.some(profile => profile.id === owner.profileId && profile.configPath === owner.record.configPath))) {
- throw new Error("Aside route ownership names a profile outside the account manifest");
- }
- if (manifestText(root) !== manifest) throw new Error("Aside account manifest changed during enumeration");
- const raw: unknown = JSON.parse(manifest);
- // Enumeration intentionally hides userIds. Prove that its entire profile roster was
- // derived from the manifest snapshot whose userIds this operation selects.
- const snapshot = raw as { currentAccountId: number; accounts?: Array<{ id: number; name?: string }> };
- const roster = snapshot.accounts ?? [{ id: snapshot.currentAccountId }];
- if (roster.length !== ctx.profiles.length || ctx.profiles.some((profile, index) => {
- const account = roster[index];
- return !account || account.id !== profile.id || (account.id === snapshot.currentAccountId) !== profile.current
- || (typeof account.name === "string" ? account.name : undefined) !== profile.name;
- })) throw new Error("Aside profile enumeration does not match the account identity snapshot");
- const ids = new Map();
- if (raw && typeof raw === "object" && "accounts" in raw && Array.isArray(raw.accounts)) {
- for (const account of raw.accounts) {
- if (account && typeof account === "object" && Number.isSafeInteger(account.id) && typeof account.userId === "string") {
- ids.set(account.id, account.userId);
- }
- }
- }
- const accounts = intent.accounts ?? "all";
- const selected = ctx.profiles.filter(profile => accounts === "all" || (Array.isArray(accounts) && accounts.includes(ids.get(profile.id) ?? "")));
- const results: RouteClientOutcome[] = [];
- for (const profile of ctx.profiles) {
- const scope = asideProfileScope(ctx, profile);
- const enabled = intent.enabled && selected.includes(profile) && accounts !== "none";
- if (!enabled && !scope.store.readRecords().aside) continue;
- const bound = await asideWriteInput(ctx, scope);
- results.push(await writeClient({ ...input, beforeWrite: () => {
- input.beforeWrite?.(); scope.assertBoundary();
- if (manifestText(root) !== manifest) throw new Error("Aside account identity changed during route apply");
- } }, bound, { ...intent, enabled }));
- }
- const failure = results.find(row => row.outcome === "failed") ?? results.find(row => row.outcome === "conflict")
- ?? results.find(row => row.outcome !== "current");
- if (failure) return { ...failure, client: "aside" };
- if (intent.enabled && accounts !== "none" && selected.length === 0) {
- return { client: "aside", outcome: "not_installed", message: "No local Aside profile matches the requested userIds" };
- }
- return { client: "aside", outcome: "current", target: selected.map(profile => `u${profile.id}`).join(",") };
-}
-
-async function applyClient(input: RouteIntentApplyInput, client: RouteClientId, intent: RouteClientIntent): Promise {
- if (client === "aside") return input.withFileWrites ? input.withFileWrites(() => applyAside(input, intent)) : applyAside(input, intent);
- if (isIntegrationClientId(client)) {
- const write = () => writeClient(input, { clientId: client, config: input.config, port: input.config.port, models: input.models,
- env: input.env, home: input.home, store: input.store, modelNames: intent.modelNames }, intent);
- return input.withFileWrites ? input.withFileWrites(write) : write();
- }
- if (input.applyNative) { input.beforeWrite?.(); return input.applyNative(client, intent); }
- const previous = readRouteIntentReceipt(input.statePath);
- return !intent.enabled && !previous?.clients[client]?.enabled
- ? { client, outcome: "current" }
- : { client, outcome: "failed", message: "This native client needs its existing local ownership adapter" };
-}
-
-/** Home and Child share the same local materializer; absent intent never calls this function. */
-export async function materializeRouteIntent(input: RouteIntentApplyInput): Promise {
- const path = input.statePath ?? routeIntentStatePath();
- if (flights.has(path)) throw new Error("Local route intent apply is already running");
- flights.add(path);
- try {
- input = { ...input, document: structuredClone(input.document), config: structuredClone(input.config), models: structuredClone([...input.models]),
- store: input.store ?? createIntegrationStateStore() };
- const effective = effectiveRouteIntent(input.document, path, input.localOff);
- const previous = input.retainSkippedOutcomes ? readRouteIntentReceipt(path) : null;
- const receipt: RouteIntentReceipt = { version: 1, models: input.models, ...(input.linkId ? { linkId: input.linkId } : {}), document: structuredClone(input.document), ...effective,
- outcomes: ROUTE_CLIENT_IDS.map(client => ({ client, outcome: "stale" })),
- appliedRouteIntentDigest: null, appliedAt: (input.now ?? (() => new Date()))().toISOString() };
- // Withdraw the previous digest before any client can change, including a failed/aborted write.
- const saveReceipt = () => input.withReceiptWrite
- ? input.withReceiptWrite(() => writeRouteIntentReceipt(receipt, path)) : writeRouteIntentReceipt(receipt, path);
- saveReceipt();
- for (const client of ROUTE_CLIENT_IDS) {
- if (input.onlyClients && !input.onlyClients.includes(client)) {
- const kept = previous?.linkId === input.linkId
- ? previous?.outcomes.find(row => row.client === client && row.intentDigest === routeClientIntentDigest(input.document, client)) : undefined;
- if (kept) receipt.outcomes[ROUTE_CLIENT_IDS.indexOf(client)] = kept;
- continue;
- }
- input.beforeWrite?.();
- try {
- receipt.outcomes[ROUTE_CLIENT_IDS.indexOf(client)] = { ...await applyClient({ ...input, overwriteConflict: input.overwriteConflict === true && (input.overwriteClient === undefined || input.overwriteClient === client) }, client, effective.clients[client]),
- intentDigest: routeClientIntentDigest(input.document, client),
- ...(effective.sources[client] === "local" ? { source: "local" as const } : {}) };
- } catch {
- input.beforeWrite?.(); // Ownership loss propagates instead of looking like a disk failure.
- receipt.outcomes[ROUTE_CLIENT_IDS.indexOf(client)] = { client, outcome: "failed", intentDigest: routeClientIntentDigest(input.document, client),
- ...(effective.sources[client] === "local" ? { source: "local" as const } : {}), message: "Local client could not be reconciled safely" };
- }
- }
- input.beforeWrite?.();
- // A local opt-out intentionally differs from the Home document; do not claim its digest.
- const locallyChanged = ROUTE_CLIENT_IDS.some(id => effective.sources[id] === "local" && input.document.clients[id].enabled);
- if (!locallyChanged && receipt.outcomes.every(row => row.outcome === "current")) receipt.appliedRouteIntentDigest = routeIntentDigest(input.document);
- saveReceipt();
- return receipt;
- } finally { flights.delete(path); }
-}
-
-/**
- * Put a standalone integration back: one still owned, or one the person changed (their own block),
- * is kept as is; one the link disabled is applied again. An uninstalled client has nothing to do.
- */
-async function restoreStandaloneClient(input: RouteIntentApplyInput, client: IntegrationClientId, modelNames?: "alias" | "routed"): Promise {
- const bound: IntegrationWriteInput = { clientId: client, config: input.config, port: input.config.port, models: input.models,
- env: input.env, home: input.home, store: input.store, modelNames };
- const state = readIntegrationState(bound);
- if (input.store?.readRecords()[client] || state.state !== "absent") return { client, outcome: "current", target: state.configPath };
- const result = await applyIntegrationCoordinated(bound, { guard: () => { input.beforeWrite?.(); return null; } });
- return result.ok || result.reason === "not_installed" ? { client, outcome: "current", target: state.configPath } : project(client, result, state.configPath);
-}
-
-/**
- * Disconnect calls this while it holds its lifecycle fence, before clearing intent receipts.
- * `standalone` names file clients the Child owned before linking; they are restored, not removed.
- */
-export async function cleanupRouteIntent(input: RouteIntentApplyInput,
- standalone: Partial> = {}): Promise {
- const receipt = readRouteIntentReceipt(input.statePath);
- if (!receipt) return [];
- const document = structuredClone(receipt.document);
- for (const id of ROUTE_CLIENT_IDS) document.clients[id].enabled = false;
- const kept = ROUTE_CLIENT_IDS.filter((id): id is IntegrationClientId => isIntegrationClientId(id) && id !== "aside" && Object.hasOwn(standalone, id));
- const outcomes = (await materializeRouteIntent({ ...input, document, overwriteConflict: false,
- onlyClients: ROUTE_CLIENT_IDS.filter(id => isIntegrationClientId(id) && !(kept as readonly string[]).includes(id)) }))
- .outcomes.filter(row => isIntegrationClientId(row.client) && !(kept as readonly string[]).includes(row.client));
- const store = input.store ?? createIntegrationStateStore();
- for (const client of kept) {
- const restore = () => restoreStandaloneClient({ ...input, store }, client, standalone[client]?.modelNames);
- outcomes.push(input.withFileWrites ? await input.withFileWrites(restore) : await restore());
- }
- return outcomes;
-}
diff --git a/src/link/child-parity.ts b/src/link/child-parity.ts
index bd7cc4d6b53..220ffbc7f43 100644
--- a/src/link/child-parity.ts
+++ b/src/link/child-parity.ts
@@ -14,12 +14,11 @@
*/
import { CODEX_SETTINGS_DIGEST_PATTERN } from "./codex-settings";
import { LINK_MANAGEMENT_PREFIX } from "./management-routes";
-import { parseRouteClientOutcomes, ROUTE_INTENT_DIGEST_PATTERN, routeClientOutcomesForPeer, type RouteClientOutcome } from "./route-intent";
/** The one write a Child may send its Home over the link. */
export const LINK_CHILD_PARITY_PATH = `${LINK_MANAGEMENT_PREFIX}/child-parity`;
/** A report is four short fields; anything larger is refused while it is still being read. */
-export const LINK_CHILD_PARITY_MAX_BYTES = 8192;
+export const LINK_CHILD_PARITY_MAX_BYTES = 1024;
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
/** 2001-09-09 .. 2286-11-20: any epoch-millisecond time a real clock reports. */
@@ -34,8 +33,6 @@ export interface LinkChildParityReport {
runtimeStartedAt: number;
/** `appliedCodexSettingsDigest` of the applied settings; null before any apply. */
appliedCodexSettingsDigest: string | null;
- appliedRouteIntentDigest?: string | null;
- routeClientOutcomes?: RouteClientOutcome[];
/**
* Increases with every report this runtime process sends. With `runtimeStartedAt` it orders
* reports, so a late POST from an older runtime or an earlier send never replaces a newer one.
@@ -48,8 +45,6 @@ export interface LinkChildParity {
lastApplyAt: number | null;
runtimeStartedAt: number | null;
appliedCodexSettingsDigest: string | null;
- appliedRouteIntentDigest?: string | null;
- routeClientOutcomes?: RouteClientOutcome[];
/** Epoch ms the Home received the report (the Home's clock; the other times are the Child's). */
reportedAt: number | null;
}
@@ -70,9 +65,7 @@ function exactKeys(value: unknown, keys: readonly string[]): value is Record = {};
- if (Object.hasOwn(value, "appliedRouteIntentDigest")) {
- const routeDigest = value.appliedRouteIntentDigest;
- if (routeDigest !== null && (typeof routeDigest !== "string" || !ROUTE_INTENT_DIGEST_PATTERN.test(routeDigest))) return null;
- const outcomes = parseRouteClientOutcomes(value.routeClientOutcomes);
- if (!outcomes) return null;
- route = { appliedRouteIntentDigest: routeDigest, routeClientOutcomes: routeClientOutcomesForPeer(outcomes) };
- }
- return { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence, ...route };
+ return { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence };
}
export interface LinkChildParityStore {
@@ -115,10 +100,6 @@ export function createLinkChildParityStore(now: () => number = Date.now): LinkCh
lastApplyAt: report.lastApplyAt,
runtimeStartedAt: report.runtimeStartedAt,
appliedCodexSettingsDigest: report.appliedCodexSettingsDigest,
- ...(report.routeClientOutcomes === undefined ? {} : {
- appliedRouteIntentDigest: report.appliedRouteIntentDigest ?? null,
- routeClientOutcomes: routeClientOutcomesForPeer(report.routeClientOutcomes),
- }),
reportedAt: now(),
sequence: report.sequence,
});
@@ -128,7 +109,7 @@ export function createLinkChildParityStore(now: () => number = Date.now): LinkCh
const kept = reports.get(linkId);
if (!kept) return { ...EMPTY_LINK_CHILD_PARITY };
const { sequence: _sequence, ...parity } = kept;
- return { ...parity, ...(parity.routeClientOutcomes === undefined ? {} : { routeClientOutcomes: routeClientOutcomesForPeer(parity.routeClientOutcomes) }) };
+ return parity;
},
clear() { reports.clear(); },
};
diff --git a/src/link/management-routes.ts b/src/link/management-routes.ts
index c2b859f0a8c..98500320fca 100644
--- a/src/link/management-routes.ts
+++ b/src/link/management-routes.ts
@@ -16,8 +16,6 @@
export const LINK_MANAGEMENT_PREFIX = "/v1/link-management";
/** The Home's OpenCodex-owned Codex settings a Child applies when it syncs. */
export const LINK_CODEX_SETTINGS_PATH = `${LINK_MANAGEMENT_PREFIX}/codex-settings`;
-export const LINK_ROUTE_INTENT_PATH = `${LINK_MANAGEMENT_PREFIX}/route-intent`;
-export const LINK_ROUTE_CATALOG_PATH = `${LINK_MANAGEMENT_PREFIX}/route-catalog`;
export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [
"/api/system/health",
@@ -73,7 +71,7 @@ export function linkManagementTarget(url: URL): URL | null {
/** Home side: may the hub-link listener dispatch this request to the link management handler? */
export function linkManagementRouteAllowed(url: URL, req: Request): boolean {
if (req.headers.has("upgrade")) return false;
- if (url.pathname === LINK_CODEX_SETTINGS_PATH || url.pathname === LINK_ROUTE_INTENT_PATH || url.pathname === LINK_ROUTE_CATALOG_PATH) return req.method === "GET" && url.search === "";
+ if (url.pathname === LINK_CODEX_SETTINGS_PATH) return req.method === "GET" && url.search === "";
const target = linkManagementTarget(url);
return target !== null && linkManagementReadAllowed(req.method, target.pathname, target.searchParams);
}
diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts
index b50281cb55c..ff6d84e6ed8 100644
--- a/src/link/remote-windows.ts
+++ b/src/link/remote-windows.ts
@@ -1,4 +1,3 @@
-import type { RouteClientId } from "./route-intent";
import { Buffer } from "node:buffer";
import { deflateRawSync, inflateRawSync } from "node:zlib";
@@ -211,27 +210,16 @@ Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{
`);
}
-/**
- * The first OpenCodex release whose `ocx sync` understands --overwrite-conflict and
- * --overwrite-client; an earlier one ignores both and reports a plain sync as success. The
- * lightweight ocx-child supports neither and never reads route intent.
- */
-export const ROUTE_OVERWRITE_MIN_CHILD_VERSION = "2.100.0";
-
-export function windowsPinnedSyncArgv(linkId: string, overwriteConflict = false, overwriteClient?: RouteClientId): string[] {
- // Overwrite needs a Child that honours it: refuse ocx-child and older ocx before syncing.
- const minimum = overwriteConflict ? ROUTE_OVERWRITE_MIN_CHILD_VERSION.split(".").map(part => `'${part}'`).join(",") : "'2','93','0'";
- const lacks = overwriteConflict ? "ocx-fanout:child-ocx-lacks-overwrite" : "ocx-fanout:child-ocx-lacks-expect-link";
- const ephemeral = overwriteConflict ? String.raw`if(([IO.Path]::GetFileName($x)).ToLowerInvariant().StartsWith('ocx-child')){[Console]::Error.WriteLine('ocx-fanout:child-ephemeral-no-overwrite');exit 121};` : "";
- return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: ["sync", "--expect-link", linkId, "--json", ...(overwriteConflict ? ["--overwrite-conflict"] : []), ...(overwriteClient ? ["--overwrite-client", overwriteClient] : [])] }) + OCX_CANDIDATES + String.raw`
+export function windowsPinnedSyncArgv(linkId: string): string[] {
+ return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: ["sync", "--expect-link", linkId, "--json"] }) + OCX_CANDIDATES + String.raw`
foreach($x in ($c|select -Unique)){
- if(![IO.File]::Exists($x)){continue};` + ephemeral + String.raw`$v=[OcxJob]::V($x);if($null -eq $v){continue};$v=$v.Trim();if($v.StartsWith('opencodex ')){$v=$v.Substring(10)}
+ if(![IO.File]::Exists($x)){continue};$v=[OcxJob]::V($x);if($null -eq $v){continue};$v=$v.Trim();if($v.StartsWith('opencodex ')){$v=$v.Substring(10)}
$m=[regex]::Match($v,'^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$')
$bad=(!$m.Success -or $v.Length -gt 128);if(!$bad -and $m.Groups[4].Success){foreach($p in $m.Groups[4].Value.Split('.')){if(!$p -or ($p -match '^\d+$' -and $p.Length -gt 1 -and $p[0] -eq '0')){$bad=$true}}}
if($bad){[Console]::Error.WriteLine('ocx-fanout:child-ocx-version-unknown');exit 121}
- $a=@($m.Groups[1].Value,$m.Groups[2].Value,$m.Groups[3].Value);$b=@(` + minimum + String.raw`);$cmp=0
+ $a=@($m.Groups[1].Value,$m.Groups[2].Value,$m.Groups[3].Value);$b=@('2','93','0');$cmp=0
for($i=0;$i -lt 3;$i++){if($a[$i].Length -ne $b[$i].Length){$cmp=$a[$i].Length-$b[$i].Length;break};$cmp=[string]::CompareOrdinal($a[$i],$b[$i]);if($cmp -ne 0){break}}
- if($cmp -lt 0 -or ($cmp -eq 0 -and $m.Groups[4].Success)){[Console]::Error.WriteLine('` + lacks + String.raw`');exit 121}
+ if($cmp -lt 0 -or ($cmp -eq 0 -and $m.Groups[4].Success)){[Console]::Error.WriteLine('ocx-fanout:child-ocx-lacks-expect-link');exit 121}
[Console]::Error.WriteLine('ocx-fanout:selected:'+$v);[Console]::Out.WriteLine('ocx-fanout:stdout-begin');Invoke-Native $x @($d.args)
};exit 127
`);
diff --git a/src/link/route-intent-watch.ts b/src/link/route-intent-watch.ts
deleted file mode 100644
index 1f740c5cca6..00000000000
--- a/src/link/route-intent-watch.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-/** Ephemeral Home wakeups. The admitted link id, never a caller selector, owns each slot. */
-export const ROUTE_INTENT_WATCH_HEADER = "x-ocx-route-intent-watch";
-export const ROUTE_INTENT_AFTER_HEADER = "x-ocx-route-intent-after";
-export const ROUTE_INTENT_WAKE_HEADER = "x-ocx-route-intent-wake";
-export const ROUTE_INTENT_WATCH_TIMEOUT_MS = 30_000;
-export const ROUTE_INTENT_WATCH_MAX_LINKS = 256;
-export type RouteIntentWake = "notified" | "timeout" | "cancelled" | "busy";
-
-export class RouteIntentWatch {
- private readonly waiters = new Map void>();
- constructor(private readonly timeoutMs = ROUTE_INTENT_WATCH_TIMEOUT_MS, private readonly maxLinks = ROUTE_INTENT_WATCH_MAX_LINKS) {
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > ROUTE_INTENT_WATCH_TIMEOUT_MS
- || !Number.isInteger(maxLinks) || maxLinks < 1 || maxLinks > ROUTE_INTENT_WATCH_MAX_LINKS) throw new RangeError("invalid route intent watch bounds");
- }
- get size(): number { return this.waiters.size; }
- wait(linkId: string, signal: AbortSignal): Promise {
- if (signal.aborted) return Promise.resolve("cancelled");
- if (this.waiters.has(linkId) || this.waiters.size >= this.maxLinks) return Promise.resolve("busy");
- return new Promise(resolve => {
- const finish = (reason: RouteIntentWake) => {
- clearTimeout(timer);
- signal.removeEventListener("abort", abort);
- this.waiters.delete(linkId);
- resolve(reason);
- };
- const abort = () => finish("cancelled");
- const timer = setTimeout(() => finish("timeout"), this.timeoutMs);
- timer.unref();
- this.waiters.set(linkId, finish);
- signal.addEventListener("abort", abort, { once: true });
- if (signal.aborted) abort();
- });
- }
- /** False means no active watcher accepted the request; it is never an apply acknowledgment. */
- notify(linkId: string): boolean {
- const wake = this.waiters.get(linkId);
- wake?.("notified");
- return wake !== undefined;
- }
-}
-export const homeRouteIntentWatch = new RouteIntentWatch();
diff --git a/src/link/route-intent.ts b/src/link/route-intent.ts
deleted file mode 100644
index de1d578aedb..00000000000
--- a/src/link/route-intent.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-/** Home policy only. No filesystem paths, listener facts, credentials or profile slots. */
-import { createHash } from "node:crypto";
-import { hostname } from "node:os";
-import { EXPORT_CLIENT_IDS } from "../clients/client-ids";
-import type { OcxConfig } from "../types";
-
-export const ROUTE_CLIENT_IDS = ["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS] as const;
-export type RouteClientId = (typeof ROUTE_CLIENT_IDS)[number];
-export interface RouteClientIntent {
- enabled: boolean;
- modelNames?: "alias" | "routed";
- /** Stable Aside user ids, never machine-local profile slots. */
- accounts?: "all" | "none" | string[];
-}
-export interface HomeRouteIntentDocument {
- version: 1;
- home: { name: string; version: string };
- clients: Record;
- /**
- * `native` appears only in the Home's own document: the Home's persisted native switch governs
- * that client there. Documents sent to a Child never carry it.
- */
- sources: Record;
- /** Device decisions (including inherit) invalidate matching enrollment overrides. */
- clientRevisions?: Partial>;
-}
-export interface RouteIntentState {
- version: 1;
- defaults: Partial>;
- devices: Record>>;
- revisions?: Record>>;
-}
-export interface RouteClientOutcome {
- client: RouteClientId;
- outcome: "current" | "stale" | "not_installed" | "conflict" | "failed";
- /** A Child's off-only opt-out; Home policy remains unchanged. */
- source?: "local";
- target?: string;
- message?: string;
- intentDigest?: string;
- /** Display-only initial selection made during enrollment; never mutates Home policy. */
- initialIntent?: RouteClientIntent;
- initialRevision?: number;
-}
-export const ROUTE_INTENT_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
-export function isRouteClientId(value: unknown): value is RouteClientId {
- return typeof value === "string" && (ROUTE_CLIENT_IDS as readonly string[]).includes(value);
-}
-function record(value: unknown): value is Record {
- return value !== null && typeof value === "object" && !Array.isArray(value);
-}
-function keys(value: unknown, required: readonly string[], optional: readonly string[] = []): value is Record