Skip to content
Closed
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
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ A failed link does not silently switch to a local provider.
The Child returns a retryable `503` without forwarding the link key or request when its tunnel is failed, stopped, or not supervised.
It also keeps the key local when another process owns the tunnel port; free that port so the Child's SSH tunnel can bind it.

## What a Child shows and manages

A Child runs on the Home's providers, accounts and settings, so the Home is where you change them.

- **Dashboard.** The Child's dashboard shows the Home's state read-only and says "Managed by Home <name> over Remote Link". Dashboard and Providers read the Home through the link, and the usage strip in the header shows the Home's quota. The model list is not shown on a Child, because building it runs provider discovery on the Home; Codex on the Child already offers the Home's models. For the same reason, the delegation and sidecar model pickers on a Child list the native models and the Home's current choices, not the Home's full routed list. Saving a change there is refused with a message that names the Home; make the change in the Home's dashboard. The Child's browser never receives a Home credential.
- **Codex settings.** `ocx sync` on the Child (and connecting it) applies the Codex settings OpenCodex manages on the Home: Fast mode, the multi-agent mode, the web search sidecar switch, and the native sub-agent defaults when the Home syncs them. `ocx sync` and `ocx status` list the values that came from the Home. The default reasoning effort and the effort levels shown in the Codex app's model picker are Codex app settings on each computer; OpenCodex does not manage them on any computer.
- **CLI.** Commands that only read, such as `ocx account list`, `ocx provider quota` and `ocx usage`, read the Home. They show what the Home has already observed: a read from a Child never makes the Home probe a provider, refresh a token or start a process, so quota and account details are as fresh as the Home's own activity keeps them. Reads that only exist to run provider discovery or a forced refresh, such as `ocx models live`, `ocx models preset show` and `ocx account list --quota`, are refused like changes. Commands that change configuration are refused with one message that names the Home and the link and says no request was sent. `ocx status` describes the Home and the link. `ocx provider list` and `ocx config get` still show this computer's own configuration, labelled as local and inactive while the computer is linked. `ocx config set`, `ocx config unset` and `ocx config import` still write locally, each with the same note, and `ocx provider add`, `remove` and `set-default` are refused because those providers are not used while linked.

These reads need an OpenCodex version with the Remote Link management relay on both the Home and the Child. An older Home answers with a message asking you to upgrade it.

## Remove a Child

Select **Disconnect** for the Child and confirm the alias. The Home stops the tunnel, revokes that Child's link key, and removes the saved link record.
Expand Down
8 changes: 8 additions & 0 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,14 @@ export default function App() {
onFocusOrphaned={focusAfterSwitcher}
/>
)}
{/* Outside the page boundary: a page that fails to render must not hide whose state this is. */}
{targetsSettled && targets.link && (
<Notice tone="warn">
{targets.link.home
? t("connection.linkManaged", { home: targets.link.home, linkId: targets.link.linkId })
: t("connection.linkManagedUnnamed", { linkId: targets.link.linkId })}
</Notice>
)}
<ErrorBoundary
key={shellPage}
pageName={t(PAGE_TKEY[shellPage])}
Expand Down
15 changes: 14 additions & 1 deletion gui/src/api-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface ApiTargets {
shared: ApiTarget;
apiKeyId?: string;
catalogSyncedAt?: string;
/** Remote Link Child: the shared plane is the Home, read-only, relayed by this machine. */
link?: { linkId: string; home: string | null };
}

export interface MachineStatusV1 {
Expand All @@ -93,6 +95,7 @@ export interface MachineStatusV1 {
connectedAt: string;
catalogSyncedAt?: string;
hubReachability: "unknown" | "online" | "offline" | "unauthorized";
link?: { linkId: string; home: string | null; readOnly: true };
}

function trimBase(value: string): string {
Expand Down Expand Up @@ -140,6 +143,15 @@ function validStatus(value: unknown): value is MachineStatusV1 {
&& (row.catalogSyncedAt === undefined || typeof row.catalogSyncedAt === "string");
}

/** The optional Remote Link block; a malformed one is ignored rather than trusted. */
function linkFromStatus(value: unknown): ApiTargets["link"] {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const row = value as Record<string, unknown>;
if (typeof row.linkId !== "string" || !/^lnk_[0-9a-f]{16}$/.test(row.linkId)) return undefined;
const home = typeof row.home === "string" && row.home.trim() && row.home.length <= 255 ? row.home.trim() : null;
return { linkId: row.linkId, home };
}

export function relayUrlForPath(shared: ApiTarget, path: string): string {
if (shared.transport !== "relay" || (!path.startsWith("/api/") && path !== "/opencodex-session")) {
throw new TypeError("path is not eligible for the fixed hub relay");
Expand Down Expand Up @@ -174,7 +186,8 @@ export function targetsFromMachineStatus(initialBase: string, status: MachineSta
const shared = status.managementTransport === "relay"
? target("shared", `${trimBase(initialBase)}/api/machine/hub-relay`, sharedOrigin, "relay")
: target("shared", sharedOrigin, sharedOrigin, "direct");
return { connected: true, machine, shared, apiKeyId: status.apiKeyId, catalogSyncedAt: status.catalogSyncedAt };
const link = linkFromStatus(status.link);
return { connected: true, machine, shared, apiKeyId: status.apiKeyId, catalogSyncedAt: status.catalogSyncedAt, ...(link ? { link } : {}) };
}

export function apiBaseForPlane(plane: ApiPlane, targets: ApiTargets): string {
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3498,6 +3498,8 @@ 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.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",
"connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?",
"connection.pairing.hub": "Hub",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3578,6 +3578,8 @@ 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.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",
"connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?",
"connection.pairing.hub": "Hub",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3487,6 +3487,8 @@ 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.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",
"connection.disconnectConfirm": "Déconnecter cette machine du hub et la redémarrer en mode autonome ?",
"connection.pairing.hub": "Hub",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3520,6 +3520,8 @@ 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.linkManaged": "Remote Link ({linkId}) 経由で Home {home} が管理しています。このダッシュボードは Home の設定とクォータを読み取り専用で表示します。変更は Home で行ってください。",
"connection.linkManagedUnnamed": "Remote Link ({linkId}) 経由で Home が管理しています。このダッシュボードは Home の設定とクォータを読み取り専用で表示します。変更は Home で行ってください。",
"connection.disconnect": "Disconnect from hub",
"connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?",
"connection.pairing.hub": "ハブ",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3520,6 +3520,8 @@ export const ko: Record<TKey, string> = {
"models.aliasStale": "오래됨",
"connection.discovering": "로컬 및 공유 대상을 확인하는 중…",
"connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.",
"connection.linkManaged": "Remote Link({linkId})를 통해 Home {home}이(가) 관리합니다. 이 대시보드는 Home의 설정과 할당량을 읽기 전용으로 보여 줍니다. 변경은 Home에서 하세요.",
"connection.linkManagedUnnamed": "Remote Link({linkId})를 통해 Home이 관리합니다. 이 대시보드는 Home의 설정과 할당량을 읽기 전용으로 보여 줍니다. 변경은 Home에서 하세요.",
"connection.disconnect": "허브 연결 해제",
"connection.disconnectConfirm": "이 머신의 허브 연결을 해제하고 독립 실행 모드로 다시 시작할까요?",
"connection.pairing.hub": "허브",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3539,6 +3539,8 @@ 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.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",
"connection.disconnectConfirm": "Desconectar esta máquina do hub e reiniciá-la no modo standalone?",
"connection.pairing.hub": "Hub",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3521,6 +3521,8 @@ 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.linkManaged": "Управляется Home {home} через Remote Link ({linkId}). Эта панель показывает настройки и квоту Home только для чтения; вносите изменения на Home.",
"connection.linkManagedUnnamed": "Управляется вашим Home через Remote Link ({linkId}). Эта панель показывает настройки и квоту Home только для чтения; вносите изменения на Home.",
"connection.disconnect": "Disconnect from hub",
"connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?",
"connection.pairing.hub": "Хаб",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3521,6 +3521,8 @@ export const tr: Record<TKey, string> = {
"models.aliasStale": "eski",
"connection.discovering": "Discovering local and shared targets…",
"connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.",
"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": "Disconnect from hub",
"connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?",
"connection.pairing.hub": "Merkez",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3495,6 +3495,8 @@ export const vi: Record<TKey, string> = {
"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.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",
"connection.disconnectConfirm": "Ngắt kết nối máy này khỏi hub và khởi động lại ở chế độ độc lập (standalone mode)?",
"connection.pairing.hub": "Hub",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3484,6 +3484,8 @@ export const zhTW: Record<TKey, string> = {
"models.aliasStale": "過期",
"connection.discovering": "正在探索本機與共享目標…",
"connection.machineUnavailable": "本機機器平面無法使用。共享請求未改用本機資料。",
"connection.linkManaged": "由 Home {home} 透過 Remote Link({linkId})管理。此儀表板以唯讀方式顯示 Home 的設定與配額;請在 Home 上進行變更。",
"connection.linkManagedUnnamed": "由其 Home 透過 Remote Link({linkId})管理。此儀表板以唯讀方式顯示 Home 的設定與配額;請在 Home 上進行變更。",
"connection.disconnect": "中斷 Hub 連線",
"connection.disconnectConfirm": "要中斷此機器與 Hub 的連線,並以獨立模式重新啟動嗎?",
"connection.pairing.hub": "中樞",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3519,6 +3519,8 @@ export const zh: 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.linkManaged": "由 Home {home} 通过 Remote Link({linkId})管理。此仪表板以只读方式显示 Home 的设置和配额;请在 Home 上进行更改。",
"connection.linkManagedUnnamed": "由其 Home 通过 Remote Link({linkId})管理。此仪表板以只读方式显示 Home 的设置和配额;请在 Home 上进行更改。",
"connection.disconnect": "Disconnect from hub",
"connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?",
"connection.pairing.hub": "中心",
Expand Down
18 changes: 18 additions & 0 deletions gui/tests/api-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ describe("two-plane API targets", () => {
await expect(discoverApiTargets("")).rejects.toThrow("local machine plane unavailable");
});

test("a Remote Link Child's relay targets this machine and carries the Home it is managed by", () => {
const machine = "http://localhost";
const linked = targetsFromMachineStatus("", {
...status("relay"),
sharedBase: `${machine}/api/machine/hub-relay`,
sharedServerOrigin: machine,
link: { linkId: `lnk_${"a".repeat(16)}`, home: "home-box", readOnly: true },
});
expect(linked.shared).toMatchObject({ baseUrl: "/api/machine/hub-relay", serverOrigin: machine, transport: "relay" });
expect(linked.link).toEqual({ linkId: `lnk_${"a".repeat(16)}`, home: "home-box" });
const malformed = targetsFromMachineStatus("", {
...status("relay"),
link: { linkId: "not-a-link", home: "x", readOnly: true },
});
expect(malformed.link).toBeUndefined();
expect(targetsFromMachineStatus("", status("relay")).link).toBeUndefined();
});

test("rejects a non-string catalog fetch timestamp", async () => {
globalThis.fetch = (async () => new Response(JSON.stringify({ ...status("direct"), catalogSyncedAt: 42 }), {
headers: { "content-type": "application/json" },
Expand Down
4 changes: 2 additions & 2 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
"abort-race.test.ts": "adapters",
"account-import.test.ts": "server",
"account-pool-management-api.test.ts": "server",
"link-listener-admission.test.ts": "server",
"link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server",
"link-listener-lifecycle.test.ts": "server", "link-listener-mutation.test.ts": "server",
"link-join-route.test.ts": "server",
"core-link-boundary.test.ts": "lab",
Expand Down Expand Up @@ -518,7 +518,7 @@
"client-hub-usage.test.ts": "clients",
"client-injection-guard.test.ts": "codex-integration",
"client-lifecycle-lock.test.ts": "clients",
"client-machine-listener.test.ts": "clients",
"client-machine-listener.test.ts": "clients", "link-home-settings.test.ts": "clients", "cli-link-child.test.ts": "cli",
"client-runtime.test.ts": "clients",
"cline-client.test.ts": "clients",
"cline-pass-deepseek-v4-tool-replay.test.ts": "providers",
Expand Down
Loading
Loading