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. 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).

Expand Down
2 changes: 1 addition & 1 deletion src/client/orphan-token-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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");
Expand Down
4 changes: 3 additions & 1 deletion tests/clients/client-machine-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down
Loading