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
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ja/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ ocx start --socks5-off

接続済み hub クライアントでは `ocx stop` は接続所有の Codex ルーティング、カタログ、Grok 設定を保持します。接続を解除するには `ocx disconnect` を実行してください。 Child を `ocx stop` で正常に停止すると、リスナーを閉じる前に、その接続が所有するジャーナルから Codex のルーティングを復元します。接続情報、キー、カタログ、その他の連携設定は保持されます。Home のルーティングを再開するには `ocx start` または `ocx service start` の後に `ocx sync --restart-codex` を実行してください。起動だけではルーティングは再適用されません。`ocx restart` の通常の引き継ぎ経路ではルーティングを保持します。所有権やジャーナルの競合は失敗として報告し、復旧の証拠を保持します。クラッシュや強制終了では正常停止時の復元を実行できません。

互換性を確認できない古い通常プロキシは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。
接続済みクライアントの停止では、保護されたローカルのランタイム記録とリスナーの証明を照合します。クライアント用リスナーと通常プロキシの両方にこの確認が必要で、通常プロキシでは設定の保持機能も認証付きの読み取りで確認します。古いランタイムや確認できないランタイムは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。

既に停止済みのクライアントでは、サービス管理側が非アクティブで、既知のローカル接続先にランタイムが存在しないことを確認してから、通常の未実行結果を返します。

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ko/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ ocx start --socks5-off

연결된 hub 클라이언트에서는 `ocx stop`이 연결 소유의 Codex 라우팅, 카탈로그와 Grok 설정을 보존합니다. 연결을 해제하려면 `ocx disconnect`를 실행하세요. Child에서 `ocx stop`으로 정상 종료하면 리스너를 닫기 전에 해당 연결이 소유한 저널을 사용해 Codex 라우팅을 복원합니다. 연결 정보, 키, 카탈로그와 다른 연동 설정은 유지됩니다. Home 라우팅을 다시 사용하려면 `ocx start` 또는 `ocx service start`를 실행한 뒤 `ocx sync --restart-codex`를 실행하세요. 시작만으로는 라우팅이 다시 적용되지 않습니다. `ocx restart`의 정상 인계 경로는 라우팅을 유지합니다. 소유권이나 저널 충돌은 실패로 보고하고 복구 근거를 보존하며, 강제 종료나 충돌 시에는 정상 종료 복원이 실행되지 않습니다.

보존 기능을 확인할 수 없는 오래된 일반 프록시는 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요.
연결된 클라이언트를 중지할 때는 보호된 로컬 런타임 기록과 리스너의 신원 증명을 대조합니다. 클라이언트 리스너와 일반 프록시 모두 이 검증이 필요하며, 일반 프록시는 인증된 조회로 설정 보존 기능도 확인합니다. 오래되었거나 검증할 수 없는 런타임은 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요.

이미 중지된 클라이언트는 서비스 관리자가 비활성이고 알려진 로컬 주소에 런타임이 없음을 확인한 뒤 정상적인 미실행 결과를 반환합니다.

Expand Down
7 changes: 4 additions & 3 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,10 @@ catalog and other integrations remain in place. After a Child stop, run `ocx sta
re-inject routing. The bound `ocx restart` handoff preserves it. Use `ocx disconnect` to release the
connection. Conflicting ownership or edited/unverified journal files produce a restoration failure
and retain recovery evidence; abrupt crashes and forced termination cannot run orderly cleanup.
A connected stop verifies the local runtime before changing its service: a client-role listener
with the matching PID is supported, while an ordinary proxy must explicitly advertise connected-client ownership handling
across API, signal and service shutdown. Older or unverified ordinary runtimes are refused before
A connected stop verifies the listener against the protected local runtime record before changing
its service. Both client-role listeners and ordinary proxies must prove that identity; an ordinary
proxy must also advertise connected-client ownership handling across API, signal and service
shutdown in an authenticated read. Older or unverified runtimes are refused before
stop actions. Update/restart that runtime through its supervisor and retry; use `ocx disconnect`
only when you intend to release the connection-owned integrations.
Repeated stops of an already-stopped client confirm service-manager inactivity and absence at the
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ru/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ ocx start --socks5-off

На подключённом клиенте hub команда `ocx stop` сохраняет маршрутизацию Codex, каталог и настройки Grok, принадлежащие соединению. Для отключения выполните `ocx disconnect`. При штатной остановке Child командой `ocx stop` маршрутизация Codex восстанавливается из журнала, принадлежащего этому соединению, до закрытия слушателя. Соединение, ключ, каталог и остальные интеграции сохраняются. Чтобы снова направлять запросы в Home, выполните `ocx start` или `ocx service start`, затем `ocx sync --restart-codex`: один запуск не применяет маршрутизацию повторно. Штатная передача управления через `ocx restart` сохраняет маршрутизацию. Конфликт владельца или журнала сообщает об ошибке и сохраняет данные для восстановления; аварийное завершение или принудительная остановка не выполняют штатное восстановление.

Старая обычная версия прокси без подтверждённой поддержки сохранения отклоняется до остановки службы или процесса. Обновите и перезапустите её через управляющий супервизор, затем повторите команду.
При остановке подключённого клиента команда сверяет доказательство идентичности слушателя с защищённой локальной записью рантайма. Это требуется и для клиентского слушателя, и для обычного прокси; обычный прокси также должен подтвердить сохранение настроек через аутентифицированный запрос чтения. Старые или непроверенные рантаймы отклоняются до остановки службы или процесса. Обновите и перезапустите рантайм через управляющий супервизор, затем повторите команду.

Для уже остановленного клиента команда подтверждает неактивность службы и отсутствие рантайма на известных локальных адресах, затем возвращает обычный результат «не запущен».

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ ocx start --socks5-off

已连接的 hub 客户端在 `ocx stop` 时保留连接拥有的 Codex 路由、目录和 Grok 配置。要解除连接,请运行 `ocx disconnect`。 在 Child 上通过 `ocx stop` 正常停止时,会在关闭监听器之前,仅从该连接拥有的日志恢复 Codex 路由。连接信息、密钥、目录和其他集成配置保持不变。要恢复 Home 路由,请先运行 `ocx start` 或 `ocx service start`,再运行 `ocx sync --restart-codex`;仅启动不会重新应用路由。`ocx restart` 的正常交接路径会保留路由。所有权或日志冲突会报告失败并保留恢复依据;崩溃或强制终止无法执行正常停止时的恢复。

若旧版普通代理无法确认支持保留配置,命令会在停止服务或进程前拒绝操作。请通过管理该运行时的应用或服务更新并重启它,然后重试。
停止已连接的客户端时,命令会依据受保护的本地运行时记录验证监听器的身份证明。客户端监听器和普通代理都必须通过此验证;普通代理还必须通过经过身份验证的读取确认配置保留能力。对于旧版或无法验证的运行时,命令会在停止服务或进程前拒绝操作。请通过管理该运行时的应用或服务更新并重启它,然后重试。

对于已停止的客户端,命令会确认服务管理器未活动且已知本地端点没有运行时,然后返回正常的“未运行”结果。

Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,7 @@
"startup-prompt.test.ts": "server",
"state-store-sweeper.test.ts": "oauth",
"stepfun-provider.test.ts": "providers",
"stop-deferred-teardown.test.ts": "service", "stop-direct-fetch.test.ts": "service",
"stop-deferred-teardown.test.ts": "service", "connected-stop-capability-auth.test.ts": "service", "stop-direct-fetch.test.ts": "service",
"storage-cleanup.test.ts": "storage",
"storage-mutation-race.test.ts": "storage",
"storage-policy-config-race.test.ts": "storage",
Expand Down
71 changes: 46 additions & 25 deletions src/cli/stop-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { stripGrokConfig } from "../grok/inject";
import { readClientConnectionState, type ClientConnectionState } from "../client/state";
import { readPid, readRuntimePort } from "../config/process-state";
import { loadConfig } from "../config";
import { configuredAdminToken } from "../lib/admin-secrets";
import { readBoundedResponseBody } from "../lib/bounded-body";
import { createLocalAttestationChallenge, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof } from "../lib/local-management-attestation";
import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability";
import { gracefulStopHost, isProcessAlive, ProxyOwnershipRefusedError } from "../lib/process-control";
import { directLocalHttpFetch } from "../server/direct-local-http";
import { fetchBoundLocalManagementRead } from "../server/local-management-read-client";
import { findLiveProxy, probeEndpointLiveness, proveLiveProxyOwnedByHome } from "../server/proxy-liveness";
import { observeGuardedManagerStopped } from "../service/guarded-manager-target";
import type { StartExitTeardown } from "./dispatch";
Expand Down Expand Up @@ -35,38 +37,57 @@ export function restoreCodexRoutingOnExit(): { success: boolean; message: string
type ConnectedStopTarget = { pid: number; port: number; hostname?: string };
const CONNECTED_STOP_PROBE_MS = 2000;
const unsupportedConnectedStop = () => new ProxyOwnershipRefusedError(
"Connected-client stop refused: runtime identity, shutdown preservation or safe runtime absence could not be verified. Inspect 'ocx service status' and update/restart an incompatible runtime through its supervisor, then retry; use 'ocx disconnect' only if you intend to release those integrations.",
"Connected-client stop refused: runtime identity, shutdown ownership handling or safe runtime absence could not be verified. Inspect 'ocx service status' and update/restart an incompatible runtime through its supervisor, then retry; use 'ocx disconnect' only if you intend to release those integrations.",
"connected_client_stop_unsupported",
);

/** A positive, PID-bound contract is required; old and new builds may share a version. */
/** Require the pinned runtime's proof before accepting its shutdown contract. */
export async function assertConnectedClientStopSupported(target: ConnectedStopTarget | null): Promise<void> {
if (!target) throw unsupportedConnectedStop();
if (!isProcessAlive(target.pid)) return; // A manager may already have ended this exact target.
const origin = `http://${gracefulStopHost(target.hostname)}:${target.port}`;
const runtime = readRuntimePort(target.pid);
if (!runtime?.attestationSecret || runtime.pid !== target.pid || runtime.port !== target.port
|| gracefulStopHost(runtime.hostname) !== gracefulStopHost(target.hostname)) throw unsupportedConnectedStop();
const endpoint = { ...target, hostname: gracefulStopHost(runtime.hostname) };
const readPinnedRuntime = () => {
const current = readRuntimePort(target.pid);
return current?.pid === runtime.pid && current.port === runtime.port && current.hostname === runtime.hostname
&& current.attestationSecret === runtime.attestationSecret ? current : null;
};
const origin = `http://${endpoint.hostname}:${endpoint.port}`;
const deadline = Date.now() + CONNECTED_STOP_PROBE_MS;
const signal = AbortSignal.timeout(CONNECTED_STOP_PROBE_MS);
const probe = async (path: string, authenticated: boolean): Promise<Record<string, unknown> | null> => {
try {
const headers: Record<string, string> = {};
if (authenticated) {
const token = configuredAdminToken(process.env.OPENCODEX_HOME?.trim() || undefined, process.env);
if (token) headers["x-opencodex-api-key"] = token;
}
const response = await directLocalHttpFetch(`${origin}${path}`, { method: "GET", headers, redirect: "error", signal },
{ timeoutMs: CONNECTED_STOP_PROBE_MS });
if (!response.ok) { void response.body?.cancel().catch(() => {}); return null; }
const body = await readBoundedResponseBody(response, { maxBytes: 4096, signal, fatalUtf8: true });
if (body.truncated) return null;
const parsed: unknown = JSON.parse(body.text);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch { return null; }
const readDocument = async (response: Response): Promise<Record<string, unknown> | null> => {
if (!response.ok) { void response.body?.cancel().catch(() => {}); return null; }
const body = await readBoundedResponseBody(response, { maxBytes: 4096, signal, fatalUtf8: true });
if (body.truncated) return null;
const parsed: unknown = JSON.parse(body.text);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
};
const health = await probe("/healthz", false);
// The native machine listener owns its transport-specific signal cleanup and 404s /api/stop.
if (health?.service === "opencodex" && health.role === "client" && health.pid === target.pid) return;
const capability = await probe("/api/stop", true);
if (capability?.schema === "ocx-stop-capabilities/1"
&& capability.pid === target.pid && capability.connectedClientOwnership === true) return;
try {
const nonce = createLocalAttestationChallenge();
const response = await directLocalHttpFetch(`${origin}/healthz`, {
method: "GET", headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: nonce }, redirect: "error", signal,
}, { timeoutMs: CONNECTED_STOP_PROBE_MS });
if (!readPinnedRuntime() || !verifyLocalAttestationProof(runtime.attestationSecret, nonce, target.pid, target.port,
response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER))) {
void response.body?.cancel().catch(() => {});
throw unsupportedConnectedStop();
}
const health = await readDocument(response);
if (!readPinnedRuntime() || health?.service !== "opencodex" || health.pid !== target.pid) throw unsupportedConnectedStop();
// Native machine listeners own transport-specific signal cleanup and have no /api/stop route.
if (health.role === "client") return;
const remaining = deadline - Date.now();
if (remaining <= 0) throw unsupportedConnectedStop();
const read = await fetchBoundLocalManagementRead({ ...endpoint, source: "runtime" }, LOCAL_MANAGEMENT_READ_PATHS.stop, {
readRuntime: readPinnedRuntime, timeoutMs: remaining, requireResponseProof: true,
});
if (read.kind !== "response") throw unsupportedConnectedStop();
const capability = await readDocument(read.response);
if (readPinnedRuntime() && capability?.schema === "ocx-stop-capabilities/1"
&& capability.pid === target.pid && capability.connectedClientOwnership === true) return;
} catch { /* A missing or replaced runtime never grants shutdown authority. */ }
throw unsupportedConnectedStop();
}

Expand Down
4 changes: 3 additions & 1 deletion src/lib/local-management-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const LOCAL_MANAGEMENT_READ_PATHS = {
startupHealth: "/api/startup-health",
providerQuotas: "/api/provider-quotas",
usageTimeline: "/api/usage/timeline",
stop: "/api/stop",
} as const;

export type LocalManagementReadPath =
Expand Down Expand Up @@ -50,7 +51,8 @@ function isLocalManagementReadPath(path: string): path is LocalManagementReadPat
|| path === LOCAL_MANAGEMENT_READ_PATHS.usage
|| path === LOCAL_MANAGEMENT_READ_PATHS.startupHealth
|| path === LOCAL_MANAGEMENT_READ_PATHS.providerQuotas
|| path === LOCAL_MANAGEMENT_READ_PATHS.usageTimeline;
|| path === LOCAL_MANAGEMENT_READ_PATHS.usageTimeline
|| path === LOCAL_MANAGEMENT_READ_PATHS.stop;
}

function localReadCapabilityPayload(
Expand Down
Loading
Loading