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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ On a Child, `GET /healthz` only says that OpenCodex is running. `GET /readyz` (a

The reason `client_state_changed` means the running Child's saved connection is missing or now names another owner or link. Its machine and link status endpoints also return `503` with this reason. Stop or restart the Child and check its connection before re-linking; this readiness result does not itself disconnect the relay.

If `ocx connect status --json` reports `disconnected` but a leftover service token prevents a new link, inspect the situation on the Child. After confirming that preserving the existing token files is intended, run `ocx disconnect --orphaned-token --yes`. It refuses a connected or pending client and any live client runtime or unverified runtime state. It also refuses a Hub, a non-loopback bind, a configured or matching admission secret, and an installed or unverified service: these can use the token for admission. On a Hub, manage the credential through the Hub service; otherwise inspect the bind, API keys, token environment and `ocx service status`, and remove or migrate a service only if intended before retrying. It moves the token and any rotation backup into a private directory under the OpenCodex config directory, prints the location, and does not infer that either token belonged to the link. On Windows, recovery requires hard-link support to preserve file identity while closing handles; if it reports an incomplete backup, inspect the reported directory and original files before retrying. Stop OpenCodex to restore: move the saved files back only when the original names are absent. The command does not revoke a Home key or restore missing client configuration.
If `ocx connect status --json` reports `disconnected` but a leftover service token prevents a new link, inspect the situation on the Child. After confirming that preserving the existing token files is intended, run `ocx disconnect --orphaned-token --yes`. It refuses a connected or pending client and any live client runtime or unverified runtime state. It also refuses a Hub, a non-loopback bind, a configured or matching admission secret, and an installed or unverified service: these can use the token for admission. On a Hub, manage the credential through the Hub service; otherwise inspect the bind, API keys, token environment and `ocx service status`, and remove or migrate a service only if intended before retrying. It moves the token and any rotation backup into a private directory under the OpenCodex config directory, prints the location, and does not infer that either token belonged to the link. On Windows, recovery requires hard-link support to preserve file identity while closing handles; if it reports an incomplete backup, inspect the reported directory and original files before retrying. If recovery was interrupted and a retry refuses with `unsafe token file` because another hard link exists, remove the leftover `.service-api-token.identity` (or `.service-api-token.prev.identity`) file from the `orphaned-token-*` folder after checking that the original token file is still in place, then retry. Stop OpenCodex to restore: move the saved files back only when the original names are absent. The command does not revoke a Home key or restore missing client configuration.

To confirm which Home is serving a Child, compare the `x-opencodex-served-by` header on the Child's `GET /v1/models` with the `instanceId` in the Home's `GET /healthz`. The Child passes the Home's header through unchanged, so the two match while the link works. The value is an opaque id, never a hostname; see [Home identity and the served-by header](/reference/management-api/#home-identity-and-the-served-by-header).

Expand Down
12 changes: 6 additions & 6 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { SectionSwitcher } from "./components/SectionSwitcher";
import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared";
import { Notice, Select, ToastNotice, type NoticeTone } from "./ui";
import { bootstrapApiSession, configureApiTargets, DEVICE_PROOF_CHANGED_EVENT, hasApiSession, hasRememberedDashboardDevice, installApiAuthFetch, logoutApiSession, SESSION_CHANGED_EVENT, SESSION_UNAVAILABLE_EVENT } from "./api";
import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, runtimeRoleFromDocument, standaloneApiTargets, type ApiTargets } from "./api-targets";
import { apiBaseForPlane, ClientStateChangedError, discoverApiTargets, isConnectedRuntime, runtimeRoleFromDocument, standaloneApiTargets, type ApiTargets } from "./api-targets";
import { ConnectPairingForm } from "./connect-pairing";
import { validateRemoteLinkSession } from "./connect-pairing-transport";
import { type Page } from "./app-routing";
Expand Down Expand Up @@ -156,7 +156,7 @@ export default function App() {
// Gating the page on discovery made a plain install show remote-hub loading copy before
// its own dashboard, for a feature the operator never enabled.
const [targetsSettled, setTargetsSettled] = useState(() => !isConnectedRuntime());
const [targetError, setTargetError] = useState(false);
const [targetError, setTargetError] = useState<"unavailable" | "client-state-changed" | null>(null);
const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared"));
const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0);
const [hubPairingOpen, setHubPairingOpen] = useState(false);
Expand Down Expand Up @@ -207,11 +207,11 @@ export default function App() {
}
if (controller.signal.aborted) return;
setSharedSessionReady(hasApiSession("shared"));
setTargetError(false);
setTargetError(null);
setTargetsSettled(true);
}).catch(() => {
}).catch(error => {
if (controller.signal.aborted) return;
setTargetError(true);
setTargetError(error instanceof ClientStateChangedError ? "client-state-changed" : "unavailable");
setTargetsSettled(true);
});
return () => controller.abort();
Expand Down Expand Up @@ -665,7 +665,7 @@ export default function App() {
need the machine plane report their own errors.
*/}
{targetError && (
<div className="alert alert-err" role="alert">{t("connection.machineUnavailable")}</div>
<div className="alert alert-err" role="alert">{t(targetError === "client-state-changed" ? "connection.clientStateChanged" : "connection.machineUnavailable")}</div>
)}
{((targets.connected && !sharedSessionReady) || remotePairingFormOpen) && (
<ConnectPairingForm key={`${targets.shared.serverOrigin}:${targets.shared.bootstrapPath}`} target={targets.shared} focusOnMount={remotePairingFormOpen} onConnected={async signal => {
Expand Down
22 changes: 21 additions & 1 deletion gui/src/api-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,23 @@ export function apiBaseForPlane(plane: ApiPlane, targets: ApiTargets): string {
return targets[plane].baseUrl;
}

/**
* A Remote Link Child whose saved connection is missing or names another owner or link answers
* its machine and link status with `503 { error: "client_state_changed" }` (docs: Remote Link).
*/
export class ClientStateChangedError extends Error {
constructor() {
super("client_state_changed");
this.name = "ClientStateChangedError";
}
}

export async function isClientStateChangedResponse(response: Response): Promise<boolean> {
if (response.status !== 503 || response.bodyUsed) return false;
const body = await response.json().catch(() => null) as { error?: unknown } | null;
return body?.error === "client_state_changed";
}

export async function discoverApiTargets(initialBase: string, signal?: AbortSignal): Promise<ApiTargets> {
const standalone = standaloneApiTargets(initialBase);
// Standalone asks nothing.
Expand All @@ -219,7 +236,10 @@ export async function discoverApiTargets(initialBase: string, signal?: AbortSign
throw new Error("local machine plane unavailable", { cause: error });
}
if (response.status === 404) return standalone;
if (!response.ok) throw new Error(`local machine plane refused discovery (${response.status})`);
if (!response.ok) {
if (await isClientStateChangedResponse(response)) throw new ClientStateChangedError();
throw new Error(`local machine plane refused discovery (${response.status})`);
}
const body = await response.json().catch(() => null);
if (!validStatus(body)) throw new Error("local machine plane returned invalid status");
return targetsFromMachineStatus(initialBase, body);
Expand Down
5 changes: 3 additions & 2 deletions gui/src/connect-pairing-transport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beginApiSessionHandoff, fetchApiSessionValidation, hasApiSession } from "./api";
import type { ApiTarget } from "./api-targets";
import { isClientStateChangedResponse, type ApiTarget } from "./api-targets";
import { discardResponseBody, withFetchDeadline } from "./bounded-fetch";
import { parseRemoteLinkStatus } from "./remote-link-api";

Expand All @@ -8,7 +8,7 @@ let pairingDeadlineMs = 10_000;
export function setPairingDeadlineForTests(ms: number): void { pairingDeadlineMs = ms; }

export class PairingError extends Error {
readonly kind: "invalid-code" | "refused" | "origin-denied" | "cloudflare-challenge" | "remote-link-unauthorized" | "remote-link-forbidden" | "unreachable" | "request-failed" | "invalid-response";
readonly kind: "invalid-code" | "refused" | "origin-denied" | "cloudflare-challenge" | "remote-link-unauthorized" | "remote-link-forbidden" | "client-state-changed" | "unreachable" | "request-failed" | "invalid-response";
constructor(kind: PairingError["kind"]) {
super(`pairing_${kind}`);
this.kind = kind;
Expand Down Expand Up @@ -135,6 +135,7 @@ export async function validateRemoteLinkSession(apiBase: string, fetchImpl?: typ
deadline.throwIfAborted();
if (!response.ok) {
if (await isHtmlChallenge(response)) throw new PairingError("cloudflare-challenge");
if (await isClientStateChangedResponse(response)) throw new PairingError("client-state-changed");
discardResponseBody(response);
if (response.status === 401) throw new PairingError("remote-link-unauthorized");
if (response.status === 403) throw new PairingError("remote-link-forbidden");
Expand Down
1 change: 1 addition & 0 deletions gui/src/connect-pairing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function ConnectPairingForm({
: error === "origin-denied" ? "connection.pairing.originDenied"
: error === "cloudflare-challenge" ? "connection.pairing.cloudflareChallenge"
: error === "remote-link-unauthorized" || error === "remote-link-forbidden" ? "connection.pairing.sessionNotAuthorized"
: error === "client-state-changed" ? "connection.clientStateChanged"
: error === "invalid-response" ? "connection.pairing.responseError" : "connection.pairing.error")}
</p>}
</form>
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3840,6 +3840,7 @@ export const de: Record<TKey, string> = {
"models.aliasStale": "veraltet",
"connection.discovering": "Discovering local and shared targets…",
"connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.",
"connection.clientStateChanged": "Die gespeicherte Remote-Link-Verbindung dieses Child fehlt oder verweist jetzt auf einen anderen Besitzer oder Link und passt nicht mehr zum laufenden Child. Stoppe oder starte dieses Child neu und prüfe seine Verbindung, bevor du es erneut verknüpfst.",
"connection.linkManaged": "Verwaltet von Home {home} über Remote Link ({linkId}). Dieses Dashboard zeigt die Einstellungen und das Kontingent des Home nur lesend an; Änderungen erfolgen auf dem Home.",
"connection.linkManagedUnnamed": "Verwaltet von seinem Home über Remote Link ({linkId}). Dieses Dashboard zeigt die Einstellungen und das Kontingent des Home nur lesend an; Änderungen erfolgen auf dem Home.",
"connection.disconnect": "Disconnect from hub",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3920,6 +3920,7 @@ export const en = {
"models.aliasStale": "stale",
"connection.discovering": "Discovering local and shared targets…",
"connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.",
"connection.clientStateChanged": "This Child's saved Remote Link connection is missing or now names another owner or link, so it no longer matches the running Child. Stop or restart this Child and check its connection before linking it again.",
"connection.linkManaged": "Managed by Home {home} over Remote Link ({linkId}). This dashboard shows the Home's settings and quota read-only; make changes on the Home.",
"connection.linkManagedUnnamed": "Managed by its Home over Remote Link ({linkId}). This dashboard shows the Home's settings and quota read-only; make changes on the Home.",
"connection.disconnect": "Disconnect from hub",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3829,6 +3829,7 @@ export const fr: Record<TKey, string> = {
"models.aliasStale": "obsolète",
"connection.discovering": "Détection des cibles locale et partagée…",
"connection.machineUnavailable": "Le plan machine local est indisponible. Les requêtes partagées n'ont pas été redirigées localement.",
"connection.clientStateChanged": "La connexion Remote Link enregistrée de ce Child est absente ou désigne désormais un autre propriétaire ou lien, et ne correspond plus au Child en cours d'exécution. Arrêtez ou redémarrez ce Child et vérifiez sa connexion avant de le lier à nouveau.",
"connection.linkManaged": "Géré par le Home {home} via Remote Link ({linkId}). Ce tableau de bord affiche les paramètres et le quota du Home en lecture seule ; effectuez les modifications sur le Home.",
"connection.linkManagedUnnamed": "Géré par son Home via Remote Link ({linkId}). Ce tableau de bord affiche les paramètres et le quota du Home en lecture seule ; effectuez les modifications sur le Home.",
"connection.disconnect": "Déconnecter du hub",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3862,6 +3862,7 @@ export const ja: Record<TKey, string> = {
"models.aliasStale": "古い",
"connection.discovering": "Discovering local and shared targets…",
"connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.",
"connection.clientStateChanged": "この Child に保存された Remote Link 接続が見つからないか、別の所有者またはリンクを指しているため、実行中の Child と一致しません。再リンクする前に、この Child を停止または再起動し、接続を確認してください。",
"connection.linkManaged": "Remote Link ({linkId}) 経由で Home {home} が管理しています。このダッシュボードは Home の設定とクォータを読み取り専用で表示します。変更は Home で行ってください。",
"connection.linkManagedUnnamed": "Remote Link ({linkId}) 経由で Home が管理しています。このダッシュボードは Home の設定とクォータを読み取り専用で表示します。変更は Home で行ってください。",
"connection.disconnect": "Disconnect from hub",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3862,6 +3862,7 @@ export const ko: Record<TKey, string> = {
"models.aliasStale": "오래됨",
"connection.discovering": "로컬 및 공유 대상을 확인하는 중…",
"connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.",
"connection.clientStateChanged": "이 Child에 저장된 Remote Link 연결이 없거나 다른 소유자 또는 링크를 가리키고 있어, 실행 중인 Child와 더 이상 일치하지 않습니다. 다시 연결하기 전에 이 Child를 중지하거나 재시작하고 연결 상태를 확인하세요.",
"connection.linkManaged": "Remote Link({linkId})를 통해 Home {home}이(가) 관리합니다. 이 대시보드는 Home의 설정과 할당량을 읽기 전용으로 보여 줍니다. 변경은 Home에서 하세요.",
"connection.linkManagedUnnamed": "Remote Link({linkId})를 통해 Home이 관리합니다. 이 대시보드는 Home의 설정과 할당량을 읽기 전용으로 보여 줍니다. 변경은 Home에서 하세요.",
"connection.disconnect": "허브 연결 해제",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3881,6 +3881,7 @@ export const pt: Record<TKey, string> = {
"models.aliasStale": "obsoleto",
"connection.discovering": "Descobrindo destinos locais e compartilhados…",
"connection.machineUnavailable": "O plano da máquina local está indisponível. As requisições compartilhadas não foram redirecionadas para o local.",
"connection.clientStateChanged": "A conexão Remote Link salva deste Child está ausente ou agora aponta para outro proprietário ou link e não corresponde mais ao Child em execução. Pare ou reinicie este Child e verifique a conexão antes de vinculá-lo novamente.",
"connection.linkManaged": "Gerenciado pelo Home {home} via Remote Link ({linkId}). Este painel mostra as configurações e a cota do Home somente para leitura; faça as alterações no Home.",
"connection.linkManagedUnnamed": "Gerenciado pelo seu Home via Remote Link ({linkId}). Este painel mostra as configurações e a cota do Home somente para leitura; faça as alterações no Home.",
"connection.disconnect": "Desconectar do hub",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3863,6 +3863,7 @@ export const ru: Record<TKey, string> = {
"models.aliasStale": "устарел",
"connection.discovering": "Discovering local and shared targets…",
"connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.",
"connection.clientStateChanged": "Сохранённое подключение Remote Link этого Child отсутствует или теперь указывает на другого владельца или ссылку и больше не совпадает с запущенным Child. Остановите или перезапустите этот Child и проверьте подключение перед повторной привязкой.",
"connection.linkManaged": "Управляется Home {home} через Remote Link ({linkId}). Эта панель показывает настройки и квоту Home только для чтения; вносите изменения на Home.",
"connection.linkManagedUnnamed": "Управляется вашим Home через Remote Link ({linkId}). Эта панель показывает настройки и квоту Home только для чтения; вносите изменения на Home.",
"connection.disconnect": "Disconnect from hub",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3863,6 +3863,7 @@ export const tr: Record<TKey, string> = {
"models.aliasStale": "eski",
"connection.discovering": "Yerel ve paylaşılan hedefler aranıyor…",
"connection.machineUnavailable": "Yerel makine bağlantısı kullanılamıyor. Paylaşılan istekler yerele yönlendirilmedi.",
"connection.clientStateChanged": "Bu Child'ın kayıtlı Remote Link bağlantısı eksik veya artık başka bir sahibi ya da bağlantıyı gösteriyor, bu yüzden çalışan Child ile eşleşmiyor. Yeniden bağlamadan önce bu Child'ı durdurun veya yeniden başlatın ve bağlantısını kontrol edin.",
"connection.linkManaged": "Remote Link ({linkId}) üzerinden Home {home} tarafından yönetiliyor. Bu pano Home'un ayarlarını ve kotasını salt okunur gösterir; değişiklikleri Home üzerinde yapın.",
"connection.linkManagedUnnamed": "Remote Link ({linkId}) üzerinden Home tarafından yönetiliyor. Bu pano Home'un ayarlarını ve kotasını salt okunur gösterir; değişiklikleri Home üzerinde yapın.",
"connection.disconnect": "Merkez bağlantısını kes",
Expand Down
Loading
Loading