diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index f01da20e03f..a162851a342 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -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). diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 33b13809c32..04fd4287d04 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -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"; @@ -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); @@ -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(); @@ -665,7 +665,7 @@ export default function App() { need the machine plane report their own errors. */} {targetError && ( -
{t("connection.machineUnavailable")}
+
{t(targetError === "client-state-changed" ? "connection.clientStateChanged" : "connection.machineUnavailable")}
)} {((targets.connected && !sharedSessionReady) || remotePairingFormOpen) && ( { diff --git a/gui/src/api-targets.ts b/gui/src/api-targets.ts index b1b5498d9b6..b8dba87be6c 100644 --- a/gui/src/api-targets.ts +++ b/gui/src/api-targets.ts @@ -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 { + 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 { const standalone = standaloneApiTargets(initialBase); // Standalone asks nothing. @@ -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); diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts index 9b53202bf83..079359950eb 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -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"; @@ -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; @@ -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"); diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx index 32159391bfd..4f7dab4e85c 100644 --- a/gui/src/connect-pairing.tsx +++ b/gui/src/connect-pairing.tsx @@ -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")}

} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c49962426a9..cd3a871b9fe 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3840,6 +3840,7 @@ export const de: Record = { "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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8653a01498d..c830812fdd6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -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", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a81aedc2860..58315dedefe 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3829,6 +3829,7 @@ export const fr: Record = { "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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f80a10814aa..0130da3e9c1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3862,6 +3862,7 @@ export const ja: Record = { "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", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1a67fdf7a71..dd2763d2f94 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3862,6 +3862,7 @@ export const ko: Record = { "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": "허브 연결 해제", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 3e81516e51b..e8bb81029df 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3881,6 +3881,7 @@ export const pt: Record = { "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", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 610c755aba0..508517461fa 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3863,6 +3863,7 @@ export const ru: Record = { "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", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 049dfb8d023..2ea35e612ac 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3863,6 +3863,7 @@ export const tr: Record = { "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", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 0579dbe6bfd..86fb42566fc 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3837,6 +3837,7 @@ export const vi: Record = { "models.aliasStale": "cũ (stale)", "connection.discovering": "Đang khám phá các mục tiêu cục bộ (local) và được chia sẻ…", "connection.machineUnavailable": "Mặt phẳng máy cục bộ (local machine plane) không khả dụng. Các yêu cầu chia sẻ đã không được chuyển hướng cục bộ.", + "connection.clientStateChanged": "Kết nối Remote Link đã lưu của Child này bị thiếu hoặc nay trỏ đến chủ sở hữu hoặc liên kết khác, nên không còn khớp với Child đang chạy. Hãy dừng hoặc khởi động lại Child này và kiểm tra kết nối trước khi liên kết lại.", "connection.linkManaged": "Được quản lý bởi Home {home} qua Remote Link ({linkId}). Bảng điều khiển này hiển thị cài đặt và hạn mức của Home ở chế độ chỉ đọc; hãy thay đổi trên Home.", "connection.linkManagedUnnamed": "Được quản lý bởi Home của nó qua Remote Link ({linkId}). Bảng điều khiển này hiển thị cài đặt và hạn mức của Home ở chế độ chỉ đọc; hãy thay đổi trên Home.", "connection.disconnect": "Ngắt kết nối khỏi hub", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 988e23fc893..24a9dacf9e4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3826,6 +3826,7 @@ export const zhTW: Record = { "models.aliasStale": "過期", "connection.discovering": "正在探索本機與共享目標…", "connection.machineUnavailable": "本機機器平面無法使用。共享請求未改用本機資料。", + "connection.clientStateChanged": "此 Child 儲存的 Remote Link 連線已遺失,或現在指向其他擁有者或連結,與正在執行的 Child 不再一致。重新連結前,請停止或重新啟動此 Child 並檢查其連線。", "connection.linkManaged": "由 Home {home} 透過 Remote Link({linkId})管理。此儀表板以唯讀方式顯示 Home 的設定與配額;請在 Home 上進行變更。", "connection.linkManagedUnnamed": "由其 Home 透過 Remote Link({linkId})管理。此儀表板以唯讀方式顯示 Home 的設定與配額;請在 Home 上進行變更。", "connection.disconnect": "中斷 Hub 連線", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 560f581dfc3..7ac05291e29 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3861,6 +3861,7 @@ export const zh: Record = { "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": "由 Home {home} 通过 Remote Link({linkId})管理。此仪表板以只读方式显示 Home 的设置和配额;请在 Home 上进行更改。", "connection.linkManagedUnnamed": "由其 Home 通过 Remote Link({linkId})管理。此仪表板以只读方式显示 Home 的设置和配额;请在 Home 上进行更改。", "connection.disconnect": "Disconnect from hub", diff --git a/gui/tests/api-targets.test.ts b/gui/tests/api-targets.test.ts index 0eacab9243b..6b5107b22da 100644 --- a/gui/tests/api-targets.test.ts +++ b/gui/tests/api-targets.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { apiBaseForPlane, + ClientStateChangedError, discoverApiTargets, relayUrlForPath, standaloneApiTargets, @@ -145,4 +146,13 @@ describe("two-plane API targets", () => { await discoverApiTargets(""); expect(calls).toBe(1); }); + + test("a Child whose saved connection diverged reports client_state_changed instead of a generic refusal", async () => { + globalThis.fetch = (async () => Response.json({ error: "client_state_changed" }, { status: 503 })) as typeof fetch; + await expect(discoverApiTargets("")).rejects.toBeInstanceOf(ClientStateChangedError); + globalThis.fetch = (async () => Response.json({ error: "busy" }, { status: 503 })) as typeof fetch; + const generic = discoverApiTargets(""); + await expect(generic).rejects.toThrow("local machine plane refused discovery (503)"); + await expect(generic).rejects.not.toBeInstanceOf(ClientStateChangedError); + }); }); diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index 25c305d83b1..74f79038b1f 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -271,6 +271,9 @@ test("Remote Link session validation distinguishes 401, 403 and Cloudflare chall "Just a moment...", { status: 403, headers: { "content-type": "text/html" } }, )) as typeof fetch)).rejects.toMatchObject({ kind: "cloudflare-challenge" }); + await expect(validateRemoteLinkSession("https://hub.example.test", + (async () => Response.json({ error: "client_state_changed" }, { status: 503 })) as typeof fetch)) + .rejects.toMatchObject({ kind: "client-state-changed" }); }); test("pairing refuses a returned session bound to a different browser origin", async () => { diff --git a/src/client/link-readiness.ts b/src/client/link-readiness.ts index 4036833f3ce..7293e23493b 100644 --- a/src/client/link-readiness.ts +++ b/src/client/link-readiness.ts @@ -20,7 +20,9 @@ import type { ClientLinkSupervisorStatus } from "./link-tunnel"; export type LinkReadinessReason = | "tunnel_idle" | "tunnel_connecting" | "tunnel_failed" | "tunnel_unavailable" | "sidecar_invalid" | "home_unreachable" | "home_unauthorized" | "link_auth_failed" | "link_credential_unavailable" - | "link_unverified"; + | "link_unverified" + /** The Child's durable connection no longer matches the one this runtime started with. */ + | "client_state_changed"; export type LinkReadiness = { ready: true } | { ready: false; reason: LinkReadinessReason }; diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index 3e2f2c7781a..55ab72b7c9e 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -201,17 +201,11 @@ export function startMachineListener( const identity = { service: "opencodex", version: VERSION, role: "client" } as const; const facts = { uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 }; let link: LinkReadiness | null = null; - if (linkMode && connectionChanged()) { - return Response.json({ ...identity, status: "pending", ...facts, link: { state: "unavailable", reason: "client_state_changed" } }, - { status: 503, headers: { "Retry-After": "1", "Cache-Control": "no-store" } }); - } if (linkMode && deps.linkReadiness) { try { link = await deps.linkReadiness(); } catch { link = { ready: false, reason: "link_unverified" }; } } - if (linkMode && connectionChanged()) { - return Response.json({ ...identity, status: "pending", ...facts, link: { state: "unavailable", reason: "client_state_changed" } }, - { status: 503, headers: { "Retry-After": "1", "Cache-Control": "no-store" } }); - } + // Read durable ownership once, after any probe, so a change during the probe still shows. + if (linkMode && connectionChanged()) link = { ready: false, reason: "client_state_changed" }; if (link && !link.ready) { // The /readyz contract: not ready is 503 with Retry-After and status pending or failed. return Response.json({ ...identity, status: "pending", ...facts, link: { state: "unavailable", reason: link.reason } }, @@ -263,7 +257,7 @@ export function startMachineListener( if (managementPrincipal(req, managementAuth, config) !== "gui-session") { return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 }); } - if (linkMode && connectionChanged() && (url.pathname === "/api/machine/status" || url.pathname === "/api/machine/clients" || url.pathname === "/api/link/status")) { + if (linkMode && (url.pathname === "/api/machine/status" || url.pathname === "/api/machine/clients" || url.pathname === "/api/link/status") && connectionChanged()) { return Response.json({ error: "client_state_changed" }, { status: 503, headers: { "Cache-Control": "no-store" } }); } // A loopback dashboard session proves possession, not user presence: any local diff --git a/src/client/orphan-token-recovery.ts b/src/client/orphan-token-recovery.ts index b3b83ca0f4f..ca16b3e2ffb 100644 --- a/src/client/orphan-token-recovery.ts +++ b/src/client/orphan-token-recovery.ts @@ -147,6 +147,10 @@ function openSource(path: string, name: string): OpenSource | null { let entry; try { entry = lstatSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + if (entry.isFile() && !entry.isSymbolicLink() && entry.nlink > 1) { + // An interrupted Windows recovery leaves its identity pin linked to the original. + throw new Error(`orphan recovery refused: unsafe token file (another hard link exists; if an earlier recovery was interrupted, remove the leftover .${name}.identity pin from its orphaned-token-* backup folder and retry)`); + } if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1 || entry.size > 4096 || (process.platform !== "win32" && entry.uid !== process.getuid?.())) throw new Error("orphan recovery refused: unsafe token file"); const fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); @@ -194,9 +198,17 @@ function verifyPinnedFile(path: string, source: OpenSource): void { } finally { closeSync(fd); } } +/** Best effort: drop a pin only while another name still holds the original token. */ +function releasePins(pins: Map): void { + for (const pin of pins.values()) { + try { if (lstatSync(pin).nlink > 1) unlinkSync(pin); } catch { /* the refusal below still names the folder */ } + } +} + function moveToPrivateBackup(sources: Sources, deps: OrphanTokenRecoveryDeps): { backupDir: string; files: string[] } { const home = getConfigDir(); let backupDir: string | undefined; + const pins = new Map(); try { const homeStat = lstatSync(home); if (home !== sources.home || !homeStat.isDirectory() || homeStat.isSymbolicLink() @@ -216,7 +228,6 @@ function moveToPrivateBackup(sources: Sources, deps: OrphanTokenRecoveryDeps): { if (process.platform === "win32") hardenSecretDir(backupDir, { required: true }); else chmodSync(backupDir, 0o700); const windowsMove = process.platform === "win32" || deps.windowsMoveForTest === true; - const pins = new Map(); if (windowsMove) { // A private hard link keeps each original file identity allocated after its Windows handle // closes. Thus the rename cannot substitute a recycled file ID without detection. @@ -224,6 +235,7 @@ function moveToPrivateBackup(sources: Sources, deps: OrphanTokenRecoveryDeps): { if (!source || source.fd === null) continue; const pin = join(backupDir, `.${source.name}.identity`); linkSync(source.path, pin); + pins.set(source.name, pin); if (process.platform === "win32") hardenSecretPath(pin, { required: true }); const pinned = lstatSync(pin); const original = fstatSync(source.fd); @@ -232,7 +244,6 @@ function moveToPrivateBackup(sources: Sources, deps: OrphanTokenRecoveryDeps): { throw new Error("orphan recovery refused: token file changed before move"); } verifyPinnedFile(pin, source); - pins.set(source.name, pin); } const closed: number[] = []; for (const { source } of sources.entries) { @@ -262,6 +273,7 @@ function moveToPrivateBackup(sources: Sources, deps: OrphanTokenRecoveryDeps): { } verifyPinnedFile(destination, source); unlinkSync(pin); + pins.delete(source.name); } if (process.platform === "win32") hardenSecretPath(destination, { required: true }); const verified = openSource(destination, source.name); @@ -273,7 +285,10 @@ function moveToPrivateBackup(sources: Sources, deps: OrphanTokenRecoveryDeps): { } return { backupDir, files: sources.entries.filter(entry => entry.source).map(entry => entry.name) }; } catch (error) { - if (backupDir) throw new Error(`orphan recovery incomplete; inspect private backup at ${JSON.stringify(backupDir)} before retrying`, { cause: error }); + if (backupDir) { + releasePins(pins); + throw new Error(`orphan recovery incomplete; inspect private backup at ${JSON.stringify(backupDir)} before retrying`, { cause: error }); + } throw error; } } diff --git a/structure/runtime.md b/structure/runtime.md index 69b58f8db91..3d7f1c6a05c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -180,7 +180,7 @@ until shutdown. Standalone shutdown restores native Codex; connected hub clients uninstall still restore. Native client signal cleanup follows the [served-connection and supervisor boundaries](remote-link.md); client update stops use full parent restoration because client start cannot discharge standalone handover receipts. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. -`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state before and after readiness probing. If they differ, `/readyz` and authenticated machine/link status answer 503 with `client_state_changed`. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. Explicit `ocx disconnect --orphaned-token --yes` in `src/client/orphan-token-recovery.ts` preserves unowned token files only after the start lease, lifecycle lock, liveness proof and config-lock revalidation; Hub, admission-token use and installed or unverified services refuse. Windows closes source handles before rename while private hard links pin identity. Missing ownership is never reconstructed. `ocx config import` refuses to replace a connected client record under the config lock. `tests/clients/client-link-connect.test.ts` covers these refusals and local liveness. +`ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. The Remote Link Child listener checks its startup owner and link against durable client state once per `/readyz`, after readiness probing, and for authenticated machine/link status only on those routes. If they differ, `/readyz` and those status routes answer 503 with `client_state_changed`; the dashboard shows that reason with its recovery guidance. This reports divergence; it does not disconnect the relay or reconstruct a connection. `tests/clients/client-machine-listener.test.ts` covers missing and replaced records. Explicit `ocx disconnect --orphaned-token --yes` in `src/client/orphan-token-recovery.ts` preserves unowned token files only after the start lease, lifecycle lock, liveness proof and config-lock revalidation; Hub, admission-token use and installed or unverified services refuse. Windows closes source handles before rename while private hard links pin identity; a refusal removes a pin only while another name still holds the token, and an extra hard link left by an interrupted run is refused with a message naming the `.identity` pin to remove from the `orphaned-token-*` folder. Missing ownership is never reconstructed. `ocx config import` refuses to replace a connected client record under the config lock. `tests/clients/client-link-connect.test.ts` covers these refusals and local liveness. The package-tree integrity fence for live package replacement follows the [update transaction contract](ops/docs-and-release.md#package-tree-integrity-fence). After client journal reconciliation, `ocx ensure` in `src/cli/dispatch.ts` treats validated connected-client state as a successful no-op without local startup or a remote health probe; invalid or mismatched state still fails, and disconnected state keeps local ensure. `tests/cli/cli-ensure-client-state.test.ts` pins this dispatch contract so the Codex shim does not warn merely because its proxy is delegated to a hub. Startup journal reconciliation, including replacement after restart/drain, follows the [Codex-home recovery contract](codex-home.md#codex-home): already-original recovery is silent, incomplete recovery retains its journal and warns without claiming restoration, and only complete recovery that rewrites or removes an artifact reports a restore; restart admission, drain deadlines and replacement ownership are unchanged. `src/cli/start-owner-fence.ts` holds the ownership mutation lease through recovery for ordinary and supervised starts. After an early negative probe, `start` and `ensure` recheck with `START_OWNERSHIP_LIVENESS` under that lease through `observeProxyLiveness` in `src/server/proxy-liveness.ts`, probing both available loopback families concurrently for every loopback or wildcard spelling classified after the same startup normalization (including `localhost.` and case variants) within the existing per-family retry ceilings and shared deadline. Only definitive absence permits PID deletion and journal reconciliation: no alive recorded PID unless fresh readable command-line evidence confirms it is foreign, no answering runtime owner, and only unanimous aggregate connection refusals or clean foreign identities at probed endpoints. Port zero names no existing listener. Timeouts, resets, unreadable/non-200 responses, PID mismatches and exhausted deadlines remain unknown; startup refuses with a fixed message while preserving PID, journal and shared config. Unreadable/empty PID command lines and any OpenCodex invocation remain unknown; a confirmed foreign PID is never signalled. A discovered live owner preserves both records and its runtime/config bind scope in `LiveProxy.hostname`; omitted or blank hostnames become explicit `127.0.0.1` in both early and recovery discovery, so a later config change cannot replace a default loopback bind. Explicit wildcard scope remains intact, and the concrete responding address is separate `probeHostname` metadata. Integration policy uses the bind scope, so loopback discovery of a wildcard listener does not authorize tokenless Grok registration. Grok start and ensure/restart synchronization also recheck current desired-state admission after catalog discovery, immediately before the synchronous writer in `src/grok/sync.ts`; start additionally rejects work after shutdown begins or cleanup runs. This closes the same-process await gap without turning the desired-state and Grok files into a cross-process transaction. Ensure carries `probeHostname` separately through `src/cli/ensure-desired-integrations.ts` and `src/grok/sync.ts` to `src/grok/inject.ts` for the written destination; an explicit secondary loopback listener keeps its own address and port; after lease release, start applies `decideStartWithLiveOwner` and ensure follows its existing live-owner path. Cross-home sibling marking still precedes journal recovery. The bind/publication fence remains separate, and supervised recovery checks durable service ownership before liveness or mutation. `tests/cli/cli-start-journal-order.test.ts` covers an early miss; `tests/cli/cli-start-owner-absence.test.ts` covers consecutive misses and dead-owner recovery; `tests/server/proxy-liveness-recovery.test.ts` binds classification and existing budgets; `tests/server/proxy-liveness-bind-scope.test.ts` follows a missed early probe through desired-state reconciliation and the real Grok writer for runtime, orphan and configured-port discovery; `tests/server/proxy-liveness-owner-evidence.test.ts` and `tests/cli/cli-start-owner-evidence.test.ts` cover aggregate children, both listener families, shared deadlines and foreign-PID recovery. A busy preferred port is never resolved by starting somewhere else. Both questions a start asks diff --git a/tests/clients/client-link-connect.test.ts b/tests/clients/client-link-connect.test.ts index 214d56882b2..b6df4340972 100644 --- a/tests/clients/client-link-connect.test.ts +++ b/tests/clients/client-link-connect.test.ts @@ -482,12 +482,60 @@ describe("client link connection contracts", () => { expect(readFileSync(join(home, "moved-original"), "utf8")).toBe(`${key}\n`); const backupName = readdirSync(home).find(name => name.startsWith("orphaned-token-")); expect(backupName).toBeDefined(); + // Another name still holds the original, so the refusal removes its identity pin. + expect(readdirSync(join(home, backupName!))).toEqual([]); + expect(lstatSync(join(home, "moved-original")).nlink).toBe(1); + expect(err.mock.calls.flat().join(" ")).toContain("orphan recovery incomplete"); + } finally { err.mockRestore(); } + }); + }); + + test("Windows-style identity pin is kept when it is the last name of the original", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true, + windowsMoveForTest: true, onWindowsHandlesClosed: () => { + rmSync(tokenPath); + writeFileSync(tokenPath, "different-private-key\n", { mode: 0o600 }); + } }, + } as never); + expect(result).toBe(1); + const backupName = readdirSync(home).find(name => name.startsWith("orphaned-token-")); expect(readFileSync(join(home, backupName!, ".service-api-token.identity"), "utf8")).toBe(`${key}\n`); expect(err.mock.calls.flat().join(" ")).toContain("orphan recovery incomplete"); } finally { err.mockRestore(); } }); }); + test("a leftover identity pin from an interrupted recovery is named in the refusal", async () => { + await withLinkHome(async home => { + const tokenPath = join(home, "service-api-token"); + writeFileSync(tokenPath, `${key}\n`, { mode: 0o600 }); + const backupDir = join(home, "orphaned-token-2026-11-07T00-00-00-000Z-abc123"); + mkdirSync(backupDir, { mode: 0o700 }); + linkSync(tokenPath, join(backupDir, ".service-api-token.identity")); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await handleDisconnectCommand(["--orphaned-token", "--yes"], { + lifecycleLockDeps: { lockPath: join(home, "lifecycle.sqlite") }, + orphanRecoveryDeps: { inspectServiceManager: noService, acquireLease: () => ({ release() {} }), runtimeSafe: async () => true }, + } as never); + expect(result).toBe(1); + const message = err.mock.calls.flat().join(" "); + expect(message).toContain("unsafe token file"); + expect(message).toContain(".service-api-token.identity"); + expect(message).toContain("orphaned-token-"); + expect(message).not.toContain(key); + expect(readFileSync(tokenPath, "utf8")).toBe(`${key}\n`); + } finally { err.mockRestore(); } + }); + }); + test("orphan recovery refuses a held start ownership lease", async () => { await withLinkHome(async home => { const tokenPath = join(home, "service-api-token"); diff --git a/tests/clients/client-machine-listener.test.ts b/tests/clients/client-machine-listener.test.ts index a6a2e214c0d..c619653dd0d 100644 --- a/tests/clients/client-machine-listener.test.ts +++ b/tests/clients/client-machine-listener.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -15,6 +15,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; import type { ClientLinkSupervisorStatus } from "../../src/client/link-tunnel"; +import * as clientState from "../../src/client/state"; let root = ""; let previousHome: string | undefined; @@ -402,6 +403,24 @@ describe("client machine listener in link mode", () => { } finally { resolveProbe(); } }); + test("durable state is read once per readiness and never for unrelated machine routes", async () => { + const state = linkConnection(); + writeFileSync(join(root, "config.json"), JSON.stringify({ port: 0, providers: {}, runtimeRole: "client", client: state })); + const server = startMachineListener(0, { state, managementAuthState: authState(), linkReadiness: () => ({ ready: true }) }); + servers.push(server); + const headers = await guiHeaders(server); + const reads = spyOn(clientState, "readClientConnectionState"); + try { + expect((await fetch(new URL("/readyz", server.url))).status).toBe(200); + expect(reads).toHaveBeenCalledTimes(1); + reads.mockClear(); + expect((await fetch(new URL("/api/machine/not-a-route", server.url), { headers })).status).toBe(404); + expect(reads).not.toHaveBeenCalled(); + expect((await fetch(new URL("/api/machine/status", server.url), { headers })).status).toBe(200); + expect(reads).toHaveBeenCalledTimes(1); + } finally { reads.mockRestore(); } + }); + function linkListener(options: { fingerprint?: string; linkStatus?: () => ClientLinkSupervisorStatus;