From 3fda89a83bffdf40225338292d55278b6a122a08 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 10:20:49 +0900 Subject: [PATCH 1/2] fix(link): hedge leftover-pin refusal and count in-flight readiness reads --- src/client/orphan-token-recovery.ts | 2 +- tests/clients/client-machine-listener.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/orphan-token-recovery.ts b/src/client/orphan-token-recovery.ts index ca16b3e2ffb..0d3b6181e27 100644 --- a/src/client/orphan-token-recovery.ts +++ b/src/client/orphan-token-recovery.ts @@ -149,7 +149,7 @@ function openSource(path: string, name: string): OpenSource | null { 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)`); + throw new Error(`orphan recovery refused: unsafe token file (another hard link exists; an interrupted earlier recovery may have left a .${name}.identity pin in an orphaned-token-* backup folder. Remove that pin if present, otherwise inspect other hard links to this file, then 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"); diff --git a/tests/clients/client-machine-listener.test.ts b/tests/clients/client-machine-listener.test.ts index c619653dd0d..1d34b40d96e 100644 --- a/tests/clients/client-machine-listener.test.ts +++ b/tests/clients/client-machine-listener.test.ts @@ -392,6 +392,7 @@ describe("client machine listener in link mode", () => { state, managementAuthState: authState(), linkReadiness: async () => { notifyProbe(); await pending; return { ready: true }; }, }); servers.push(server); + const reads = spyOn(clientState, "readClientConnectionState"); try { const responsePromise = fetch(new URL("/readyz", server.url)); await started; @@ -400,7 +401,8 @@ describe("client machine listener in link mode", () => { const response = await responsePromise; expect(response.status).toBe(503); expect(await response.json()).toMatchObject({ link: { state: "unavailable", reason: "client_state_changed" } }); - } finally { resolveProbe(); } + expect(reads).toHaveBeenCalledTimes(1); + } finally { resolveProbe(); reads.mockRestore(); } }); test("durable state is read once per readiness and never for unrelated machine routes", async () => { From 8cf53e48a55fe9e5ba2af9afaac6d8279da2db53 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 11:07:24 +0900 Subject: [PATCH 2/2] fix(link): keep identity pins that are not links of the token --- docs-site/src/content/docs/guides/remote-link.md | 2 +- src/client/orphan-token-recovery.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index a162851a342..25aa0d8fd11 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. 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. +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, check the `orphaned-token-*` folders for a leftover `.service-api-token.identity` (or `.service-api-token.prev.identity`) file. Remove it only if it is a hard link of the current token file (the same file, not just the same name); keep any other pin, because it may be the only copy of an older token. Then inspect any remaining hard links to the token file 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. 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/src/client/orphan-token-recovery.ts b/src/client/orphan-token-recovery.ts index 0d3b6181e27..2c506a82cee 100644 --- a/src/client/orphan-token-recovery.ts +++ b/src/client/orphan-token-recovery.ts @@ -149,7 +149,7 @@ function openSource(path: string, name: string): OpenSource | null { 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; an interrupted earlier recovery may have left a .${name}.identity pin in an orphaned-token-* backup folder. Remove that pin if present, otherwise inspect other hard links to this file, then retry)`); + throw new Error(`orphan recovery refused: unsafe token file (another hard link exists; an interrupted earlier recovery may have left a .${name}.identity pin in an orphaned-token-* backup folder. Remove such a pin only if it is a hard link of this token file and keep any other pin, then inspect any remaining hard links to this file before retrying)`); } 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");