Skip to content

feat(hardening): phase 6 — key rotation, adversarial gates, seven-locale docs - #2789

Merged
lidge-jun merged 21 commits into
devfrom
codex/remote-hub-p6
Sep 1, 2026
Merged

feat(hardening): phase 6 — key rotation, adversarial gates, seven-locale docs#2789
lidge-jun merged 21 commits into
devfrom
codex/remote-hub-p6

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • Phase 6 (final) of the remote-hub stack (plan: devlog/_plan/260827_remote_hub/080_phase6_hardening.md).
  • Per-client key rotation: ocx connect rotate with a persisted pendingOperation recovery marker, <tokenfile>.prev backup owned by the service-secrets module (writeTokenBackup/restoreTokenBackup/removeOrphanTokenBackup), commit-on-doubly-accepted crash recovery, and orphan-backup cleanup for the crash window before marker persistence.
  • Management-side rotation (ocx access key rotate, GUI ApiKeys start/commit/abort with one-time secret display), session logout/invalidations, pairing rate limits.
  • Adversarial coverage: protocol skew matrix, oversized/malformed catalog fixtures, relay SSRF + header-smuggling + Connection-nominated header stripping + cancellation propagation.
  • Docs: the remote-hub guide translated into all seven docs-site locales (fr/ja/ko/ru/tr/zh-cn/zh-tw) with nav entries; GUI locale parity for every new string.

UI change

The management-side key rotation is the visible surface of this phase. Screenshot from the phase-6 head, taken against a live proxy with a key in the pending-rotation state:

opencodex API keys page showing a key in the pending-rotation state, with the two-step commit and abort controls

What the shot documents: the 키 교체 section appears only while a rotation is pending, states that the new key must be applied to clients and verified before commit, shows the cutover deadline, and offers exactly two exits — 새 키로 확정 (commit) and 키 교체 취소 (abort). The one-time secret is displayed at issuance and is not re-rendered here, which is the intended behavior. Locale shown is Korean; the same strings ship for all supported locales.

Verification

Run on lidge-ai (remote CI host) at 207254f, per the no-local-suite policy:

  • bun run typecheck — clean.
  • bun run test — full suite, exit 0, zero failing tests.
  • cd gui && bun run test — 1098 pass, 0 fail; bun run build — clean; bun run lint:gui — 0 warnings, 0 errors.
  • bun run privacy:scan — the only finding is the pre-existing devlog/_plan/260827_release_train/020_preview_release.md:36 hit already on dev; zero findings from this stack.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed (seven docs locales + reference/structure sync).
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults (rotation secrets one-time-display only, backups owner-only with orphan cleanup, no secret in logs/argv — auth-surface change, flagging for maintainer security review).

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 27, 2026 20:26
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: be811c17-7ca0-4549-b301-5f5128de5573

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the enhancement New feature or request label Aug 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

✅ READY

  • all PR quality gates passed.

@github-actions
github-actions Bot marked this pull request as draft August 27, 2026 20:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 207254fe01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/connect.ts Outdated
Comment on lines +303 to +304
const restored = restoreTokenBackup(connection.pendingOperation.oldKeyBackupPath);
await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve token identity when abort fails

If a failure occurs after the new token is installed and the subsequent abort request fails transiently, these lines restore the old token before leaving the pending marker in place. On the next recovery, both the current file and .prev therefore contain the same accepted old token; recoverRotationWithAuthority interprets both successful probes as distinct old/new candidates, commits the pending new key, and then records the old fingerprint locally, locking the client out. Abort before restoring, or persist enough identity to distinguish duplicate old-token files during recovery.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment thread src/client/state.ts
Comment on lines +92 to +95
if (backup.kind === "present" && current.kind === "present") {
try {
removeOrphanTokenBackup();
return { kind: "orphan-cleaned" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent status from deleting an in-flight rotation backup

When ocx connect status runs while rotateConnectedClientKey is awaiting /api/keys/rotate—after writeTokenBackup but before the pending marker is persisted—this inspection sees an apparent orphan and deletes it. Rotation can then install the new token and marker, but any later probe or commit failure becomes unrecoverable because .prev is gone. Serialize cleanup with rotation or persist a pre-request intent marker before making the backup visible.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment thread src/client/hub-relay.ts
Comment on lines +280 to +282
const responseBody = method === "HEAD" || !upstream.body
? null
: boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return relay errors before exposing oversized responses

For a chunked upstream response without Content-Length, this now returns the upstream status immediately and discovers the 16 MiB overflow only while the caller consumes the stream. The caller consequently observes (for example) a 200 followed by a partial-body stream error instead of the relay's established 502 JSON failure, which breaks management clients expecting a complete JSON response and stable relay error mapping. Buffer the bounded body before constructing the response, or otherwise avoid exposing the upstream status until the size check succeeds.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 63 / 80

이 PR은 remote-hub 스택 phase 6(마지막 hardening)입니다. 베이스는 codex/remote-hub-p5(PR #2786)이고, 지금은 Draft입니다. 계획 문서는 devlog/_plan/260827_remote_hub/080_phase6_hardening.md입니다. 지금 dev HEAD 50e955604에는 src/server/management/api-key-rotation.ts가 없고, src/lib/service-secrets.ts에도 토큰 백업/복구 API가 없습니다. hub connect rotate·관리 API 키 회전·적대적 게이트·remote-hub 가이드 7로케일이 한 번에 들어옵니다.

클라이언트 키 회전은 ocx connect rotatependingOperation 복구 마커, writeTokenBackup/restoreTokenBackup/removeOrphanTokenBackup으로 크래시 창을 다룹니다. 관리 쪽은 ocx access key rotate와 GUI ApiKeys start/commit/abort, 비밀은 한 번만 보여 줍니다. api-key-rotation.ts는 TTL 10분, timingSafeEqual로 rotationId를 비교하고, expire·mismatch·already-pending을 구분합니다. 적대적 커버는 프로토콜 skew, 과대/기형 catalog, relay SSRF·헤더 밀수·Connection 지명 헤더 제거·취소 전파를 잠급니다. 문서/GUI 문자열은 7로케일 패리티를 맞춥니다.

보안 방향은 맞습니다. 회전 비밀을 argv/로그에 안 남기려는 테스트가 있고, orphan backup 정리가 있습니다. 다만 지금 CI는 빨강입니다. enforce-target은 UI 스크린샷 누락으로 실패했고(ApiKeys GUI를 만지므로), gates/macos/test 일부도 실패로 보입니다. Draft라 머지 대상은 아직 아닙니다. types/config에 pendingRotation·관련 필드가 조금 늘어 types.ts/config.ts 분할 캠페인 면적이 커집니다. preview deploy는 계획에 없습니다. p5(#2786)와 p4(#2781 draft) 위 스택이라 dev 단독 머지는 불가합니다.

라인 src/server/management/api-key-rotation.ts - start/commit/abort, TTL, timingSafeEqual, pendingRotation.

라인 src/lib/service-secrets.ts - writeTokenBackup/restoreTokenBackup/removeOrphanTokenBackup.

라인 src/cli/connect.ts / src/client/connect.ts - ocx connect rotate, pendingOperation 복구.

라인 gui/.../ApiKeys*.tsx / api-keys-utils.ts - start/commit/abort UI, 일회 비밀 표시.

라인 src/client/hub-relay.ts / tests/client-hub-relay.test.ts - SSRF·헤더 밀수·취소 전파.

라인 src/types/config.ts / src/config.ts - pendingRotation 등 스키마. 분할 캠페인 면적 증가.

라인 docs-site/.../guides/remote-hub.md (+7 locales) - remote-hub 가이드 번역·nav.

경로 base - #2786(p5) 위. p4 draft·p5 CI/ready 전이면 이 PR도 대기입니다.

메인테이너의 판단이 필요한 지점

  • ApiKeys UI 스크린샷을 붙여 enforce-target을 통과시킬지, draft로 두고 p5 안정화 후 올릴지
  • 로컬에서 빨간 gates/test가 환경 문제인지 실제 회귀인지(작성자는 lidge-ai에서 full green이라 함)
  • pendingRotation을 types/config 분할 전에 넣어도 되는지

너의 추천
Draft 유지하세요. p4→p5가 ready·초록이 된 뒤, UI 스크린샷을 넣고 CI를 다시 초록으로 만든 다음 ready로 올리세요. 회전 비밀 one-time·argv 금지는 유지하세요. types/config 추가는 최소 필드로 두세요.

이 댓글은 grok-bot이 작성했습니다

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardening scope is valuable, but this cannot receive a final security approval as an intermediate Draft stack head. Exact head 207254fe012406aca66c90a1370f773ec0ceace0 targets codex/remote-hub-p5; the repository integration policy requires dev (or scoped dev2-go), and Phases 1-5 are not all landed. The current 95-file diff therefore includes inherited stack state rather than only the Phase 6 change. Resolve the predecessor chain, retarget/rebase the final Phase 6 delta onto current dev, complete the readiness gates, and rerun exact-head cross-platform/runtime/GUI CI. Then re-request non-author security review of rotation crash recovery, token backup ownership and cleanup, one-time secret display, session invalidation, pairing rate limits, relay SSRF/header stripping, and cancellation. No merge or maintainer-sponsored label should be applied to this intermediate head.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head 4ab13b4635d336a48a5d5fac9aee29d85b289f99.

The abort-before-restore ordering fixes the earlier token-identity failure, but two current-head recovery/relay blockers remain:

  1. inspectClientRotationRecoveryGate() re-reads pendingOperation, but rotation writes .prev before that marker. A concurrent ocx connect status in the pre-marker window still sees current+backup with no pending operation and deletes the live backup as an orphan. The reread cannot observe a marker that has not been persisted yet. Serialize across processes with the same coordinator/rotation lock, or persist a durable pre-request intent before exposing the backup; add a deterministic concurrent status/rotate regression.

  2. For chunked hub responses, the relay returns the upstream status and a bounded stream immediately. If the stream later exceeds 16 MiB, the caller has already observed a 200 and partial body instead of the relay contract's stable 502 JSON error. Buffer through the bounded reader before exposing the response status, or otherwise withhold the upstream response until the limit is proven.

This phase remains Draft, enforce-target is red for the required GUI screenshot, and every parent is still blocked. Keep the exact head unapproved until these findings and the inherited chain are resolved.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head 2349d39e8afde25043a14c69b1da91f5a11be3dd.

The abort-before-restore ordering is preserved, but the two remaining recovery/relay blockers are not fixed:

  1. rotateConnectedClientKey() still writes .prev at src/client/connect.ts:276, starts the remote rotation, and only persists pendingOperation at lines 279-290. inspectClientRotationRecoveryGate() re-reads state at src/client/state.ts:117, but if status lands before that marker exists it still calls removeOrphanTokenBackup() at line 121. The comments acknowledge the remaining window while the code still deletes inside it. Serialize status cleanup with rotation, or persist durable intent before exposing .prev; add a deterministic cross-process status/rotate regression.
  2. src/client/hub-relay.ts:280-287 still returns the upstream status with boundedRelayResponseStream() immediately. A chunked response that crosses 16 MiB therefore exposes 200 plus a partial body before the limit failure, rather than the relay contract stable 502 JSON response. Buffer through the bounded reader before constructing the response, or otherwise withhold status until the limit is proven; cover chunked overflow at the public response boundary.

This head is still Draft, with macos, enforce-target, and aggregate ci red, and it inherits blocked parents. Keep it unapproved until the code paths and exact-head gates are resolved.

jun and others added 12 commits September 1, 2026 22:42
…an in-flight backup

Two rotation defects, plus the client-side reconciliation this phase needs
after the earlier contract changes.

Abort before restore. The rollback path restored the local token file and then
asked the hub to abort the pending rotation. When that abort failed
transiently, the process held the old key locally while the hub still had a
pending rotation for the new one — two sides disagreeing about which generation
is current, surfaced only as "rollback was incomplete". The hub is the
authority on which generation is live, so it is asked first and the local file
is rewound only after it agrees. On failure both candidates and the pending
marker stay on disk, because recovery genuinely cannot tell which generation
wins without asking.

A concurrent status no longer deletes a live backup. The orphan-cleanup branch
fires on "backup present, token present, no pending marker", which is exactly
what `ocx connect status` sees if it lands in the window where
rotateConnectedClientKey has written .prev but has not yet persisted
pendingOperation. It deleted the rollback target the in-flight rotation was
relying on. The gate now re-reads persisted state — the caller's snapshot may
predate the marker — and declines to clean while a rotation is recorded.

Client catalog reconciliation. This phase adds schema validation and the
x-opencodex-key-id echo on top of a conditional-fetch path that Phase 1 (D2)
removed, so the validator handling is dropped and the additions are kept: no
If-None-Match, no ETag in the result, and any 304 is catalog_unexpected_304.
That refusal is strictly wider than the two cases it replaces
(catalog_304_without_lkg, catalog_etag_mismatch), both of which required a
conditional request the client no longer makes.

The rotate path also still carried --allow-insecure-http; removed for the same
reason as the connect path.

tests/loopback-listener-admission.test.ts takes this phase's fix for the
ingress role assertion — supplying a complete client connection so the ingress
rule is what refuses — over the narrower split made while rebasing p5.
tests/management-route-registry.test.ts reconciles the declared registry
against source and found four routes this phase serves but never declared:
POST, POST /commit and DELETE on /api/keys/rotate, plus POST
/api/session/logout.

The rotate trio are ordinary management mutations and are declared as such.

/api/session/logout carries a session-only exemption with its reason. It ends
the CURRENT gui-session and requires that session's own Origin and CSRF, so
there is nothing for a CLI verb to act on: the CLI holds an admin token, and
this route refuses the admin token precisely so it cannot end a consent session
it never established. Giving it a CLI surface would mean inventing one.
Declaring the three rotate routes in the registry surfaced the other half of
that contract: tests/cli-capabilities.test.ts requires every management route
to be capability-covered, exempt, or in the dated ratchet. These were none of
the three, and unlike POST /api/session/logout they have a real CLI driver, so
an exemption would have been a false claim.

One capability covers all three routes because they are one operation: start
returns the new secret once, commit promotes it, abort unwinds a rotation that
could not be confirmed. Splitting them into separate verbs would imply an
operator can stop halfway, which is the state the recovery gate exists to
prevent.

skills/ocx/references/01_management_surface.md is regenerated, since it is
derived from this table.
Two surfaces this stack added still rendered for a user who never enabled
remote hub.

Key rotation was offered on every API key. It is a connected-client operation:
it swaps the data key this machine uses against its hub, through a
commit/abort handshake the hub arbitrates. A standalone install has no hub to
rotate against, so the control both advertised remote hub to someone who never
turned it on and would have driven a handshake with nothing on the other end.
The handlers are passed only for a connected runtime; without them the section
does not render.

The usage source row read "Source: local usage.jsonl" on standalone. Naming
the source answers "which store served these numbers", and that question only
exists once there are two. On a standalone install there is exactly one, so the
line said nothing the page did not already imply while still making the user
read past a sentence about topology. It renders only when connected, alongside
the machine/hub scope toggle it belongs with.
@github-actions
github-actions Bot marked this pull request as ready for review September 1, 2026 14:03
lidge-jun added a commit that referenced this pull request Sep 1, 2026
Base automatically changed from codex/remote-hub-p5 to dev September 1, 2026 14:32
@lidge-jun
lidge-jun merged commit 9232df0 into dev Sep 1, 2026
34 of 35 checks passed
@lidge-jun
lidge-jun deleted the codex/remote-hub-p6 branch September 1, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants