Skip to content

fix(server): treat forwarded loopback ports as loopback - #573

Merged
lidge-jun merged 6 commits into
devfrom
codex/260728-ssh-loopback-gate
Jul 28, 2026
Merged

fix(server): treat forwarded loopback ports as loopback#573
lidge-jun merged 6 commits into
devfrom
codex/260728-ssh-loopback-gate

Conversation

@lidge-jun

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

Copy link
Copy Markdown
Owner

Summary

A proxy reached through ssh -L 20100:localhost:10100 was refused entirely, not partially. isLoopbackRequestHost() required the Host header's port to equal the proxy's own port, so a forwarded request arriving as Host: localhost:20100 failed the check.

That gate is not CORS-only. isAllowedRequestOrigin is the admission check in front of /v1/models, /v1/responses, /v1/responses/compact, /v1/messages, /v1/chat/completions, /v1/live and the Responses WebSocket upgrade, and on a loopback bind it rejects on the Host header before looking at Origin. Codex CLI, Claude Code and curl send no Origin at all, so the whole proxy looked dead rather than misconfigured.

Loopback is a trust boundary by hostname, not by port. The sibling isLoopbackOriginValue() dropped its own port check for exactly this reason in e4e0612 ("any http/https origin on localhost/127.0.0.1/::1 is treated as same-trust-boundary"); only the Host side was left on the old rule.

Why the port check was not a defense

A DNS-rebinding browser connects to the proxy's real port and sends that port verbatim in Host. The port comparison therefore always succeeded for the attack it looked like it was stopping — isLoopbackHostname is what rejected it, and still does.

Verified against 40 Host forms. All still refused:

localhost.attacker.com   127.0.0.1.nip.io   localtest.me   localhost@evil.com
0.0.0.0   127.0.0.2   127.255.255.254   [::ffff:127.0.0.1]   lоcalhost (Cyrillic о)

WHATWG normalization folds 127.1, 2130706433, 0x7f000001 and [0:0:0:0:0:0:0:1] to genuine loopback, which is correct rather than a bypass.

Non-loopback binds are untouched: they take the isApiAuthRequired branch, which enforces token auth and refuses startup without OPENCODEX_API_AUTH_TOKEN.

Also fixed

isLoopbackHostname now strips a single trailing dot, so the FQDN form curl http://localhost.:20100/ is accepted. Same class of bug — a legitimate loopback caller refused on spelling.

Tests

This predicate had no test coverage at all before this PR. The new file pins both directions, including a characterization test for the pre-existing fail-open on an unparseable Host (if (!parsed) return true), so tightening that later requires a deliberate failing test rather than silent drift. That fail-open is unchanged here and is not browser-reachable — a browser composes Host from its own connection.

Activation evidence, not just a green suite:

$ git checkout HEAD~1 -- src/server/auth-cors.ts && bun test tests/server-loopback-host-gate.test.ts
 6 pass / 4 fail
$ git checkout HEAD -- src/server/auth-cors.ts && bun test tests/server-loopback-host-gate.test.ts
 10 pass / 0 fail

Full run:

bun run typecheck                      exit 0
bun test tests/server-auth.test.ts tests/oauth-callback-binds.test.ts \
         tests/server-loopback-host-gate.test.ts tests/oauth-accounts-api.test.ts
 73 pass / 0 fail / 408 expect() calls
bun run privacy:scan                   Privacy scan passed

tests/server-auth.test.ts:582 ("loopback management API rejects host-header same-origin rebinding") was treated as the must-not-break oracle and passes.

Docs

configuration.md gains an SSH port forwarding subsection under Remote access, with three caveats that were easy to get wrong:

  • a forwarded loopback is unauthenticatedssh -g -L, container publishing and some devcontainer/Codespaces modes bind the client side to 0.0.0.0 and expose both the management API and the data plane with no credential
  • the client base URL must be set by hand (ocx only ever writes 127.0.0.1 with the local default port)
  • provider OAuth login needs its own forward, since the callback listens on a fixed port on the remote host

Review

src/server/auth-cors.ts is under .github/CODEOWNERS as an authentication boundary, so this needs explicit security review per MAINTAINERS.md and should not self-merge. An independent adversarial audit was run against the change (40-vector Host probe, every call site of the predicate, WS upgrade path, corsHeaders behavior) and returned GO-WITH-FIXES; all of its findings are folded into this commit. That audit is a technical check, not a substitute for maintainer review.

Planning and evidence: devlog/_plan/260728_bug_bundle_resolution/010_ssh_loopback_gate.md.

Summary by CodeRabbit

  • New Features

    • Added SSH port-forwarding guidance for remote access, including OAuth callback setup and security considerations.
    • Added an interactive Yes/No selector for the startup GitHub star prompt.
    • Automated coding-agent runs now defer the star prompt until a direct user launch.
  • Bug Fixes

    • SSH forwarding now works correctly with forwarded loopback ports, including trailing-dot localhost forms.
    • Improved validation of GitHub CLI authentication before displaying the star prompt.
  • Tests

    • Added coverage for SSH loopback access, interactive prompts, and agent-driven startup behavior.

The first interactive `ocx start` asked with a typed `[y/N]` line. Replace it
with an inline selector that shows both choices, moves with the arrow keys,
and accepts `y`/`n` directly. No stays highlighted, so Enter alone still
declines, and escape or Ctrl-C decline as well.

The prompt now also requires `gh auth status` to succeed: a logged-out `gh`
cannot fulfil a Yes, so asking would only produce a failure message. Terminals
without raw mode fall back to the typed question.

Declining remains terminal — no persisted decline state and nothing injected
into any model prompt to keep nudging afterwards.
Three changes to the one-time GitHub-star question:

- The highlighted choice is now Yes, so Enter accepts. The selector shows
  which side is highlighted, so Enter never does something unannounced.
- The question names its mechanism: starring goes through the user's own
  `gh` login, so the prompt says so.
- When an agent is driving `ocx start`, the prompt is not auto-answered.
  The agent is told to ask the user and to run the star command only on a
  yes. The one-time marker stays unwritten, so the real selector still
  appears on the user's own hand-typed run.

Agent detection lives in src/cli/agent-driven.ts and is env-var based:
a false positive only postpones the prompt, while a false negative would
let an agent spend the user's GitHub identity.
WP1 docs-only cycle. Five decade docs at diff-level precision, ordered by
dependency (auth gate -> response layer -> adapter -> PR cleanup) rather
than effort:

- 010 SSH remote proxy: isLoopbackRequestHost couples loopback identity to
  port equality, so ssh -L on a different local port 403s the whole /v1/*
  data plane. The sibling isLoopbackOriginValue already dropped its port
  check in e4e0612 for the same reason.
- 020 issue #553: the same three-line 'Provider unreachable' shape repeats
  at core.ts 1197/1746/1788; fold into one helper and give
  ERR_TLS_CERT_ALTNAME_INVALID its own wording plus a verification command.
- 030 issue #545: the anthropic adapter prepends the Claude Code identity
  unconditionally on OAuth, so a classifier that already carries it loses
  output budget it capped at 64 tokens and retries.
- 040 PR #527: its first commit already landed on dev as 9dd3c42, which
  is what makes the branch dirty; rebase keeping only a64aa58.
- 050 PR #557: no code work remains, only a security-boundary decision.

No production code changed in this cycle.
Independent read-only review returned GO-WITH-FIXES with 4 blockers.

Retires WP4 outright. Its premise was inverted: skipSystemPromptPrefix
means Claude Code does NOT prepend its prefix, so the proposed dedup guard
could never fire — while the unit tests would have passed by feeding the
constant to itself. The compared strings differ anyway, and a maintainer
had already ruled on issue #545, from an outbound capture, that removing
the OAuth identity block is not a safe fix.

Also folds back:
- the ssh -g / devcontainer residual risk the research doc carried but the
  plan had dropped, now stated as accepted risk
- the CODEOWNERS security-review boundary on src/server/auth-cors.ts, so
  WP2 is NEEDS_HUMAN-eligible rather than self-merging
- OcxConfig imports from src/types, not src/config (tsc TS2459)
- a tsconfig-independent error code cast
- a range-diff precondition before skipping 1ba588e on PR #527
- server-auth.test.ts:582-602 named as WP2's must-not-break oracle
The plan assumed 1ba588e could simply be skipped because dev already
carries 9dd3c42. Measured: same six files, but dev's version is +122
lines larger (208 vs 86 insertions, 150 differing range-diff lines) — it
is a rewrite that absorbed review feedback, not a rename.

Skipping the commit blindly and resolving conflicts toward the PR side
would have silently reverted those 122 lines. The rebase procedure now
pins dev as the canonical side and requires a post-cherry-pick diff
against origin/dev to prove the delta is confined to the stale
app-server warning.
isLoopbackRequestHost() required the Host header's port to equal the
proxy's own port, so a proxy reached through `ssh -L 20100:localhost:10100`
arrived as `Host: localhost:20100` and was refused. That gate sits in
front of the whole data plane — /v1/models, /v1/responses, /v1/messages,
/v1/chat/completions, /v1/live and the Responses WebSocket upgrade — not
just CORS, and it fires with no Origin header at all, so Codex CLI,
Claude Code and curl all saw a proxy that looked completely dead rather
than one with a CORS problem.

Loopback is a trust boundary by hostname, not by port. The sibling
isLoopbackOriginValue() dropped its own port check for exactly this
reason in e4e0612. Port equality was never the rebinding defense
either: a rebinding browser connects to the real port and sends it
verbatim, so the hostname check is what rejected it before and still
does. Verified against 40 Host forms — localhost.attacker.com,
127.0.0.1.nip.io, localtest.me, 0.0.0.0, 127.0.0.2, [::ffff:127.0.0.1]
and a Cyrillic homograph all stay refused.

Also accepts the FQDN form `localhost.`, which curl sends verbatim and
which was refused for the same class of reason.

The predicate had no test coverage at all. The new file pins both
directions, including a characterization test for the pre-existing
fail-open on an unparseable Host so tightening it later needs a
deliberate failing test.

Docs: forwarding is now documented, with the caveats that a forwarded
loopback is unauthenticated (ssh -g / container publishing expose it),
that the client base URL must be set by hand, and that provider OAuth
login needs its own forward.
@github-actions github-actions Bot added the bug Something isn't working label Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds owner-ledger and bug-resolution planning documents, fixes SSH loopback admission for forwarded ports with regression coverage and documentation, and updates the CLI’s GitHub star prompt with agent detection, interactive confirmation, and authentication checks.

Changes

Owner decision ledger

Layer / File(s) Summary
Ledger scope and dated delta
devlog/_plan/260727_owner_decision_ledger/000_scope.md, devlog/_plan/260727_owner_decision_ledger/007_delta_260728.md
The ledger index, recording rules, dated repository delta, state corrections, audit findings, and question-status updates are added.
WSL/SSH intake and root cause
devlog/_plan/260727_owner_decision_ledger/008_wsl_ssh_report_intake.md, devlog/_plan/260727_owner_decision_ledger/009_ssh_remote_proxy_rootcause.md
Corrected WSL/SSH intake evidence and a confirmed port-sensitive SSH proxy root cause are documented.
Bug-bundle fixability classification
devlog/_plan/260727_owner_decision_ledger/010_bug_bundle_fixability.md
Open PRs and issues are classified by fixability, constraints, and recommended execution order.

Bug-bundle resolution plans

Layer / File(s) Summary
Work-phase map and terminal criteria
devlog/_plan/260728_bug_bundle_resolution/000_plan.md
Work phases, constraints, success criteria, terminal states, source-of-truth synchronization, and review-gate history are defined.
SSH loopback gate and remote-access documentation
src/server/auth-cors.ts, tests/server-loopback-host-gate.test.ts, docs-site/src/content/docs/reference/configuration.md, devlog/_plan/260728_bug_bundle_resolution/010_ssh_loopback_gate.md
Loopback admission now relies on hostname validation rather than configured-port equality; forwarded-port host/origin cases are tested and SSH forwarding guidance is documented.
TLS upstream error diagnosis
devlog/_plan/260728_bug_bundle_resolution/020_tls_altname_diagnosis.md
A shared TLS-aware upstream connection error helper and its planned call-site and regression coverage are specified.
Discarded Claude system dedup proposal
devlog/_plan/260728_bug_bundle_resolution/030_claude_system_dedup.md
The WP4 deduplication proposal is marked discarded while its historical implementation and test plan remain recorded.
PR rebase and security-boundary decisions
devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md, devlog/_plan/260728_bug_bundle_resolution/050_pr557_boundary.md
PR #527 rebase/conflict handling and PR #557/#533 security-gated merge decisions are documented.

Agent-aware CLI prompting

Layer / File(s) Summary
Interactive confirmation control
src/cli/interactive-confirm.ts, tests/interactive-confirm.test.ts
A raw-mode Yes/No selector with keyboard navigation, cleanup, Ctrl-C handling, and readline fallback is implemented and tested.
Agent-aware GitHub star prompt
src/cli/agent-driven.ts, src/cli/star-prompt.ts, src/cli/index.ts, tests/agent-driven.test.ts, tests/startup-prompt.test.ts
Agent-driven runs defer the star prompt without persisting the marker; direct runs require authenticated GitHub CLI access and use the new selector.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SSHClient
  participant Proxy
  participant AuthCors
  SSHClient->>Proxy: Forward request through remapped local port
  Proxy->>AuthCors: Evaluate Host and Origin
  AuthCors-->>Proxy: Accept loopback hostname independent of port
Loading
sequenceDiagram
  participant StartCommand
  participant AgentDetection
  participant GitHubCLI
  participant InteractiveConfirm
  StartCommand->>AgentDetection: Check environment
  AgentDetection-->>StartCommand: Defer or continue
  StartCommand->>GitHubCLI: Verify authentication
  GitHubCLI-->>StartCommand: Report availability
  StartCommand->>InteractiveConfirm: Show Yes/No prompt
  InteractiveConfirm-->>StartCommand: Return answer
Loading

Possibly related issues

  • #570 — The SSH loopback host and forwarded-port changes directly implement the issue’s stated objective.

Suggested reviewers: ingwannu, wibias

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main server-side change: forwarded loopback ports are now treated as loopback.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/260728-ssh-loopback-gate

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

@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: e2da6f6df3

ℹ️ 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".

resolve(answer);
// A Ctrl-C during the prompt is still a Ctrl-C: hand it back to the
// process so the normal shutdown path runs instead of being eaten here.
if (interrupted) process.kill(process.pid, "SIGINT");

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 Avoid self-signaling with process.kill on Windows

When an authenticated Windows user presses Ctrl-C at this first-run prompt, process.kill(pid, "SIGINT") terminates the process rather than delivering a graceful signal (src/lib/process-control.ts explicitly documents this Windows behavior). Because the prompt runs after the server writes its PID/runtime state and injects system configuration, this bypasses the registered shutdown cleanup and can leave stale PID files or injected client configuration behind. Propagate cancellation to handleStart or invoke its graceful shutdown path without self-killing.

AGENTS.md reference: AGENTS.md:L36-L37

Useful? React with 👍 / 👎.

Comment on lines +104 to +108
const onData = (chunk: Buffer | string) => {
const key = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (key === KEY_INTERRUPT) return finish(false, true);
if (key === KEY_ESCAPE) return finish(false, false);
if (KEY_ENTER.has(key)) return finish(yes, false);

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 Parse raw input as a byte stream

Raw stream chunks do not correspond one-to-one with keypresses, but every branch compares the entire chunk to one key sequence. If the terminal coalesces inputs such as arrow-right plus Enter into "\x1b[C\r", neither action is recognized and the prompt appears stuck; if it splits an arrow sequence after "\x1b", the first byte is misread as a standalone Escape and declines immediately. Buffer and tokenize the input rather than treating each data event as exactly one key.

Useful? React with 👍 / 👎.

Comment thread src/server/auth-cors.ts
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
// A fully-qualified "localhost." is the same host as "localhost": curl and some clients
// send the trailing dot verbatim, and refusing it 403s a legitimate loopback caller.
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, "");

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 Keep trailing-dot loopback classification consistent

When config.hostname is localhost. or 127.0.0.1., this shared predicate now makes isApiAuthRequired treat the bind as unauthenticated loopback, while the unchanged classifiers in src/codex/inject.ts and src/service.ts still treat it as non-loopback. As a result, Codex injection enters legacy remote mode and writes an env_http_headers reference to an otherwise-unrequired OPENCODEX_API_AUTH_TOKEN, and ocx service install refuses the same configuration without that token. Apply trailing-dot normalization only to parsed request hosts, or update every configuration-side classifier consistently.

Useful? React with 👍 / 👎.

forward that port too:

```bash
ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote

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 Document the provider-specific OAuth callback ports

The suggested second forward only works for ChatGPT, whose callback uses port 1455. Other supported OAuth providers listen on different fixed ports—for example Anthropic uses 54545, Google Antigravity uses 51121, and xAI uses 56121—so users following this generic ocx login <provider> guidance for those providers still receive an unreachable callback. Label 1455 as ChatGPT-specific and list or link to each provider's required callback port.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai 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.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@devlog/_plan/260727_owner_decision_ledger/008_wsl_ssh_report_intake.md`:
- Around line 53-60: Update candidate C2 and the related next-action text to
distinguish already-supported general SSH remote access from the missing `ssh -L
local-port:localhost:server-port` different-port forwarding case. Remove the
claim that remote access is unmodeled or requires a new issue, and align the
follow-up status with `009_ssh_remote_proxy_rootcause.md`.

In `@devlog/_plan/260727_owner_decision_ledger/010_bug_bundle_fixability.md`:
- Line 76: Update the affected Markdown lines in this document so issue
identifiers beginning with #, including `#557` and the referenced `#533`, `#424`,
`#447`, and `#527` occurrences, cannot be interpreted as headings; prefix each
identifier with PR or wrap it in inline code, while preserving the surrounding
Korean text and meaning.

In `@devlog/_plan/260728_bug_bundle_resolution/000_plan.md`:
- Around line 3-6: Update the audit-trail dates in the plan document’s session
metadata and the independently reviewed entries to use the actual review date,
July 27, 2026. If those entries describe future work rather than completed
events, label them explicitly as planned/future instead of claiming completion.

In `@devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md`:
- Around line 97-105: Update the post-rebase validation step around the
cherry-pick procedure to compare the complete tree, rather than limiting git
diff to src/codex/. Ensure the check includes all files changed between
1ba588eff and 9dd3c42da, including tests, and verify no assertions or review
changes were dropped before declaring the rebase safe.
- Around line 127-143: Update the PR rebase plan so it changes PR `#527`’s head as
well as its base: either force-push the rebased commits to the branch currently
backing `#527`, or explicitly close `#527` and create a replacement PR from
codex/pr527-rebase. If creating a replacement, revise all later gh pr
view/checks references and related instructions to use the new PR number.

In `@docs-site/src/content/docs/reference/configuration.md`:
- Around line 179-181: Update the documentation sentence describing loopback
handling to say that the Host has a hostname of localhost, 127.0.0.1, or ::1,
rather than saying it resolves to those values. Preserve the existing statement
that any port is accepted and the listed clients work.

In `@src/cli/interactive-confirm.ts`:
- Around line 104-122: Update the onData handler in interactive-confirm.ts to
buffer raw input and distinguish a bare Escape from split arrow-key sequences
such as \x1b[D and \x1b[C before calling finish(false, false); preserve the
existing selection updates for left/right input. Add or update coverage in
tests/interactive-confirm.test.ts lines 28-34 to verify split escape sequences
move the selection and bare Escape still cancels.

In `@src/cli/star-prompt.ts`:
- Around line 25-36: Avoid calling gh auth status for agent-driven runs by
moving the isAgentDriven() short-circuit ahead of the authentication probe in
the star prompt flow. Update ghAvailable() or its caller so gh --version may
still determine installation, but the network-backed auth check is skipped when
the prompt is deferred for agents.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3f4ec1fb-a272-4c79-90fb-4952661ac40b

📥 Commits

Reviewing files that changed from the base of the PR and between c0b902f and e2da6f6.

📒 Files selected for processing (21)
  • devlog/_plan/260727_owner_decision_ledger/000_scope.md
  • devlog/_plan/260727_owner_decision_ledger/007_delta_260728.md
  • devlog/_plan/260727_owner_decision_ledger/008_wsl_ssh_report_intake.md
  • devlog/_plan/260727_owner_decision_ledger/009_ssh_remote_proxy_rootcause.md
  • devlog/_plan/260727_owner_decision_ledger/010_bug_bundle_fixability.md
  • devlog/_plan/260728_bug_bundle_resolution/000_plan.md
  • devlog/_plan/260728_bug_bundle_resolution/010_ssh_loopback_gate.md
  • devlog/_plan/260728_bug_bundle_resolution/020_tls_altname_diagnosis.md
  • devlog/_plan/260728_bug_bundle_resolution/030_claude_system_dedup.md
  • devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md
  • devlog/_plan/260728_bug_bundle_resolution/050_pr557_boundary.md
  • docs-site/src/content/docs/reference/configuration.md
  • src/cli/agent-driven.ts
  • src/cli/index.ts
  • src/cli/interactive-confirm.ts
  • src/cli/star-prompt.ts
  • src/server/auth-cors.ts
  • tests/agent-driven.test.ts
  • tests/interactive-confirm.test.ts
  • tests/server-loopback-host-gate.test.ts
  • tests/startup-prompt.test.ts

Comment on lines +53 to +60
## 후보 재순위 (정정 후)

| # | 후보 | 근거 | 실제 강도 |
| --- | --- | --- | --- |
| C1 | **#131 회귀** — GUI/CLI 로그인 수동 폴백이 다시 깨짐 | #131이 정확히 이 증상. **PR #491이 07-27 18:59에 `src/oauth/login-cli.ts`를 포함해 승인 0건으로 머지됨** — 제보 12시간 전 | **가장 유력**. 회귀 용의자가 시간·파일 양쪽으로 맞는다 |
| C2 | SSH 포트포워딩으로 원격 프록시 사용 | 저장소가 이 구성을 모델링하지 않음 | 중. 진짜 빈 땅이지만 제보 표현과의 연결이 약함 |
| C3 | WSL2 localhost 단방향 | `doctor.ts:845`가 힌트 제공 | **낮음**. 힌트 안에 `networkingMode=mirrored` 해법이 명시돼 있고, `hostname` 설정 레버도 문서화돼 있다(`docs-site/.../configuration.md:33`) — 발견성 문제지 기능 부재가 아님 |
| C4 | shim interop 거부 | `src/codex/shim.ts:311-314` | **낮음**. 거부 메시지가 복구 명령까지 명시한다. "꼬였다"와 가장 안 맞는다 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Narrow C2 to different-port SSH forwarding.

This “corrected” intake still says the repository does not model SSH remote access and that C2 requires a new issue. That contradicts 009_ssh_remote_proxy_rootcause.md, which records that remote access is already modeled and only port-remapped forwarding was missing. Update the candidate and next-action text to distinguish general remote access from ssh -L local-port:localhost:server-port; otherwise the ledger misstates the root cause and follow-up status.

Proposed wording change
- C2 | SSH 포트포워딩으로 원격 프록시 사용 | 저장소가 이 구성을 모델링하지 않음
+ C2 | 다른 로컬 포트의 SSH 포트포워딩 | 일반 원격 접근은 모델링돼 있지만 포트 변경 포워딩은 미지원

-3. C2면 새 이슈 — 저장소가 모델링하지 않은 유일한 시나리오다.
+3. C2가 다른 로컬 포트 포워딩이면 `009_ssh_remote_proxy_rootcause.md`의 확정 원인과 수정 상태를 반영한다.

Also applies to: 73-86

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260727_owner_decision_ledger/008_wsl_ssh_report_intake.md`
around lines 53 - 60, Update candidate C2 and the related next-action text to
distinguish already-supported general SSH remote access from the missing `ssh -L
local-port:localhost:server-port` different-port forwarding case. Remove the
claim that remote access is unmodeled or requires a new issue, and align the
follow-up status with `009_ssh_remote_proxy_rootcause.md`.

**#533** `BLOCKED-BY-DEPENDENCY` · #557이 대체
파일 목록 23개가 #557과 동일하다. 조상 관계가 아니라 **더 새 dev 위에 리베이스된
별도 작업**이고, #533에 없는 두 수정(preflight 권한 게이트, job 상태 살균)을
#557이 갖고 있다. #533은 Windows 테스트 2건이 실제로 실패했고 크로스플랫폼

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Prevent issue numbers from being parsed as headings.

These lines start with #533, #424, #447, or #527, which triggers MD018 and can produce inconsistent Markdown rendering. Prefix them with PR or wrap the identifiers in code formatting.

Proposed fix
-#533에 없는 두 수정
+PR `#533에` 없는 두 수정

-#424(Grok 이미지 브리지)
+PR `#424`(Grok 이미지 브리지)

-#447·#429·#528은
+PR `#447`·#429·#528은

-#527의 base가
+PR `#527의` base가

Also applies to: 82-82, 162-162, 169-169

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 76-76: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260727_owner_decision_ledger/010_bug_bundle_fixability.md` at
line 76, Update the affected Markdown lines in this document so issue
identifiers beginning with #, including `#557` and the referenced `#533`, `#424`,
`#447`, and `#527` occurrences, cannot be interpreted as headings; prefix each
identifier with PR or wrap it in inline code, while preserving the surrounding
Korean text and meaning.

Source: Linters/SAST tools

Comment on lines +3 to +6
세션: `019fa53a-5c95-76d1-b616-faab73d044e2`
goalplan: `opencodex-bug-pr-6-7-pabcd-work-phase-wp1-docs-f`
기준: `origin/dev` = `f195e90bc`, 로컬 `dev` = `c17f51659`(미푸시 2건)
작성: 2026-07-28 (WP1 docs-only 사이클)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use an accurate date for the audit trail.

This document says it was written and independently reviewed on July 28, 2026, but the current review date is July 27, 2026. Because later terminal criteria rely on these evidence claims, either use the actual date or label the entries as planned/future events.

Also applies to: 110-123

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260728_bug_bundle_resolution/000_plan.md` around lines 3 - 6,
Update the audit-trail dates in the plan document’s session metadata and the
independently reviewed entries to use the actual review date, July 27, 2026. If
those entries describe future work rather than completed events, label them
explicitly as planned/future instead of claiming completion.

Comment on lines +97 to +105
**따라서 계획의 "그냥 건너뛴다"는 그대로 실행하면 안 된다.** 수정된 절차:

1. `a64aa5856`만 체리픽하되, **충돌이 나는 것이 정상**이다 — `a64aa5856`는
`1ba588eff` 위에 쓰였고 dev에는 그 상위집합이 있다.
2. 충돌 해소 시 **dev(`9dd3c42da`) 쪽을 무조건 정본**으로 삼는다. `1ba588eff`
버전의 코드가 남으면 리뷰 반영분이 되돌려진다.
3. `a64aa5856`가 **새로 추가하는 것만** 얹는다 — stale app-server 경고.
4. 체리픽 후 `git diff origin/dev -- src/codex/` 로 실제 델타가 stale 경고에만
국한되는지 확인한다. `9dd3c42da`의 라인이 사라졌으면 잘못된 해소다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the complete post-rebase diff.

1ba588eff and 9dd3c42da differ in six files, including test files, but the proposed check only runs git diff origin/dev -- src/codex/. That cannot detect dropped or reverted assertions under tests/; passing tests does not prove the intended coverage survived. Compare the full tree, or explicitly include all six affected files, before declaring the rebase safe.

- git diff origin/dev -- src/codex/
+ git diff --stat origin/dev --
+ git diff --name-status origin/dev --
🧰 Tools
🪛 LanguageTool

[grammar] ~102-~102: Ensure spelling is correct
Context: ...삼는다. 1ba588eff 버전의 코드가 남으면 리뷰 반영분이 되돌려진다. 3. a64aa5856새로 추가하는 것만 얹는다 — s...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md` around lines
97 - 105, Update the post-rebase validation step around the cherry-pick
procedure to compare the complete tree, rather than limiting git diff to
src/codex/. Ensure the check includes all files changed between 1ba588eff and
9dd3c42da, including tests, and verify no assertions or review changes were
dropped before declaring the rebase safe.

Comment on lines +127 to +143
### 5. 푸시 + 리타깃

```bash
git push -u origin codex/pr527-rebase
gh pr edit 527 --base dev
```

head 브랜치가 바뀌므로 실제로는 **#527을 닫고 새 PR을 여는 편이 깔끔할 수
있다.** B 단계에서 결정한다:

| 선택 | 장점 | 단점 |
| --- | --- | --- |
| 기존 #527에 force-push + 리타깃 | 이슈 링크 보존 | force-push 필요 |
| 새 PR + #527 클로즈 | 이력이 깨끗 | 링크가 새 번호로 이동 |

오너 본인 PR이고 리뷰 코멘트가 없으므로(reviewDecision 공란) **어느 쪽이든
정보 손실이 없다.** 기존 PR 유지를 우선한다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md'

echo '## File size'
wc -l "$file"

echo
echo '## Relevant excerpt (lines 90-160)'
cat -n "$file" | sed -n '90,160p'

echo
echo '## Search for PR 527 / branch references in the plan'
rg -n 'PR 527|`#527`|codex/pr527-rebase|gh pr edit|base dev|head branch|reviewDecision|force-push|새 PR' devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md

echo
echo '## Nearby related plan files'
git ls-files 'devlog/_plan/260728_bug_bundle_resolution/*' | sed -n '1,120p'

Repository: lidge-jun/opencodex

Length of output: 3224


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md'

echo '## Early plan context (lines 1-90)'
cat -n "$file" | sed -n '1,90p'

echo
echo '## Other mentions of branch/PR state in this file'
python3 - <<'PY'
from pathlib import Path
text = Path('devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md').read_text()
for needle in ['codex/catalog-written-signal', 'codex/pr527-rebase', '`#527`', 'head', 'base', 'force-push', 'reviewDecision']:
    print(f'--- {needle} ---')
    for i, line in enumerate(text.splitlines(), 1):
        if needle in line:
            print(f'{i}: {line}')
PY

echo
echo '## Related files that mention PR 527 or branch names'
python3 - <<'PY'
from pathlib import Path
root = Path('devlog/_plan/260728_bug_bundle_resolution')
for p in sorted(root.glob('*.md')):
    txt = p.read_text()
    if any(s in txt for s in ['`#527`', 'codex/pr527-rebase', 'codex/catalog-written-signal']):
        print(p)
PY

Repository: lidge-jun/opencodex

Length of output: 4691


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file='devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md'
cat -n "$file" | sed -n '160,176p'

Repository: lidge-jun/opencodex

Length of output: 718


Update the PR head, not just the base (devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md:129-131, 171-175). git push -u origin codex/pr527-rebase creates a new branch, but gh pr edit 527 --base dev only changes the target branch; PR #527 still points at the old head, so the rebased commits won’t be the ones reviewers and CI are attached to. Either push the rewritten commits to the branch that backs #527, or close #527 and open a new PR from codex/pr527-rebase, then update the later gh pr view/checks 527 steps to the new PR number.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260728_bug_bundle_resolution/040_pr527_rebase.md` around lines
127 - 143, Update the PR rebase plan so it changes PR `#527`’s head as well as its
base: either force-push the rebased commits to the branch currently backing
`#527`, or explicitly close `#527` and create a replacement PR from
codex/pr527-rebase. If creating a replacement, revise all later gh pr
view/checks references and related instructions to use the new PR number.

Comment on lines +179 to +181
The local port does not have to match the remote one. opencodex treats any request whose
`Host` resolves to `localhost`, `127.0.0.1`, or `::1` as loopback regardless of port, so
`http://localhost:20100/v1` works for Codex CLI, Claude Code, the dashboard, and `curl`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Describe hostname matching as textual validation, not DNS resolution.

isLoopbackHostname normalizes and compares exact hostname forms; it does not resolve DNS. Saying the Host “resolves to” localhost could imply that an attacker-controlled name resolving to 127.0.0.1 is trusted, which contradicts the rebinding tests. Use wording such as “has a hostname of localhost, 127.0.0.1, or ::1.”

As per path instructions, user-facing documentation must describe the exact hostname-based behavior implemented by src/server/auth-cors.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/reference/configuration.md` around lines 179 -
181, Update the documentation sentence describing loopback handling to say that
the Host has a hostname of localhost, 127.0.0.1, or ::1, rather than saying it
resolves to those values. Preserve the existing statement that any port is
accepted and the listed clients work.

Source: Path instructions

Comment on lines +104 to +122
const onData = (chunk: Buffer | string) => {
const key = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (key === KEY_INTERRUPT) return finish(false, true);
if (key === KEY_ESCAPE) return finish(false, false);
if (KEY_ENTER.has(key)) return finish(yes, false);
const lower = key.toLowerCase();
if (lower === "y") return finish(true, false);
if (lower === "n") return finish(false, false);
if (KEY_YES_SIDE.has(key)) {
yes = true;
paint();
return;
}
if (KEY_NO_SIDE.has(key) || key === KEY_TAB) {
yes = key === KEY_TAB ? !yes : false;
paint();
return;
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first.
git ls-files src/cli/interactive-confirm.ts tests/interactive-confirm.test.ts

# Show a compact outline of the implementation file.
ast-grep outline src/cli/interactive-confirm.ts --view expanded

# Show a compact outline of the test file.
ast-grep outline tests/interactive-confirm.test.ts --view expanded

# Read the relevant sections with line numbers.
sed -n '1,220p' src/cli/interactive-confirm.ts | cat -n
printf '\n--- TESTS ---\n'
sed -n '1,220p' tests/interactive-confirm.test.ts | cat -n

Repository: lidge-jun/opencodex

Length of output: 10816


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node <<'JS'
const { PassThrough } = require('node:stream');

function probeWrites(label, writes) {
  const pt = new PassThrough();
  const chunks = [];
  pt.on('data', c => chunks.push(c.toString('utf8')));
  for (const w of writes) pt.write(w);
  pt.end();
  console.log(label, JSON.stringify(chunks));
}

probeWrites('single-arrow', ['\x1b[D']);
probeWrites('split-arrow', ['\x1b', '[D']);
probeWrites('two-writes', ['a', 'b']);
probeWrites('three-writes', ['\x1b', '[', 'D']);
JS

Repository: lidge-jun/opencodex

Length of output: 264


🌐 Web query:

Node.js raw mode escape sequence chunking readline keypress timeout ESC split bytes

💡 Result:

In Node.js, managing input from a terminal in raw mode involves handling ANSI escape sequences that may be fragmented across multiple data chunks [1][2]. When a terminal sends an escape sequence—such as an arrow key (e.g., \x1b[D)—it may arrive in separate packets (e.g., \x1b followed by [D), potentially causing incorrect processing if the receiver assumes the sequence is complete within a single read [1]. Node.js addresses this through the readline module's emitKeypressEvents function, which implements a streaming parser to reconstruct these sequences [3][4]. Key technical considerations include: 1. Escape Code Timeout: When the parser receives an ambiguous sequence—such as a lone ESC (\x1b) character—it cannot immediately determine if it is a standalone Escape key press or the beginning of a longer ANSI sequence [4]. It waits for a specific duration, defined by the escapeCodeTimeout (default 500ms), for additional bytes to arrive before emitting a keypress event [5][4]. This delay ensures that valid multi-byte sequences are correctly identified rather than being split into multiple, separate events [2][6]. 2. Configuration: You can control this behavior using the escapeCodeTimeout property when creating a readline interface [4]. For scenarios requiring high-responsiveness where the standard 500ms delay is undesirable, users have requested more granular control, such as setting the timeout to zero, though current implementations generally rely on the interface-level setting [5][4]. 3. Limitations: While the built-in parser handles standard terminal navigation sequences, it does not support all possible ANSI escape codes [3][7]. For instance, complex sequences like Terminal Cursor Position Reports (\x1b[6n) may be partially misinterpreted or flow into the input stream as regular strings if the response exceeds the parser's expected buffer length [7]. For applications requiring robust, arbitrary ANSI sequence parsing beyond what readline provides, external streaming parser libraries are recommended to manage stateful buffering across chunks [8]. Top results: [3][2][6][5][4][7]

Citations:


Buffer escape sequences before treating \x1b as cancel.
src/cli/interactive-confirm.ts:104-122 assumes each 'data' chunk is a complete key. In raw mode, \x1b can arrive before the rest of an arrow-key sequence (\x1b[D / \x1b[C), so this path can resolve the prompt as Escape instead of moving the selection. Keep a small buffer or use readline’s keypress parser so bare Escape is distinguished from split escape sequences.

📍 Affects 2 files
  • src/cli/interactive-confirm.ts#L104-L122 (this comment)
  • tests/interactive-confirm.test.ts#L28-L34
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/interactive-confirm.ts` around lines 104 - 122, Update the onData
handler in interactive-confirm.ts to buffer raw input and distinguish a bare
Escape from split arrow-key sequences such as \x1b[D and \x1b[C before calling
finish(false, false); preserve the existing selection updates for left/right
input. Add or update coverage in tests/interactive-confirm.test.ts lines 28-34
to verify split escape sequences move the selection and bare Escape still
cancels.

Comment thread src/cli/star-prompt.ts
Comment on lines +25 to 36
/**
* Whether `gh` is both installed and logged in. Starring goes through the
* user's own `gh` auth, so an unauthenticated CLI cannot fulfil a "Yes" — in
* that case the prompt stays silent instead of asking for something it would
* then fail to do.
*/
function ghAvailable(): boolean {
const r = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
return !r.error && r.status === 0;
const version = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
if (version.error || version.status !== 0) return false;
const auth = spawnSync("gh", ["auth", "status"], { stdio: "ignore", timeout: 5000, windowsHide: true });
return !auth.error && auth.status === 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File map ==\n'
git ls-files src/cli/star-prompt.ts tests/startup-prompt.test.ts

printf '\n== Outline of src/cli/star-prompt.ts ==\n'
ast-grep outline src/cli/star-prompt.ts --view expanded || true

printf '\n== Relevant lines in src/cli/star-prompt.ts ==\n'
sed -n '1,180p' src/cli/star-prompt.ts | cat -n

printf '\n== Relevant lines in tests/startup-prompt.test.ts ==\n'
sed -n '1,240p' tests/startup-prompt.test.ts | cat -n

printf '\n== Search for ghAvailable / isAgentDriven usage ==\n'
rg -n "ghAvailable|isAgentDriven|printAgentDeferral|auth status|marker" src/cli/star-prompt.ts tests/startup-prompt.test.ts

Repository: lidge-jun/opencodex

Length of output: 11290


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,180p' src/cli/star-prompt.ts | nl -ba

Repository: lidge-jun/opencodex

Length of output: 197


🌐 Web query:

gh auth status generates api call graphql endpoint query current user

💡 Result:

Yes, the gh auth status command performs an API call to the GitHub GraphQL endpoint to verify authentication and retrieve information about the current user [1][2]. When you run gh auth status, the GitHub CLI iterates over your stored authentication tokens for each configured host [3][4][5]. To confirm these tokens are valid and to retrieve associated details, the command executes a query against the GitHub GraphQL API [1][2]. Technical details regarding this behavior include: 1. GraphQL Usage: The command uses a GraphQL query to identify the "viewer" (the current user) [1]. This allows the CLI to verify that the token is active and authorized [3][5]. 2. Scope Retrieval: While the GraphQL query identifies the user, token scopes are often extracted from the HTTP response headers (such as X-OAuth-Scopes) returned during this API interaction [1][2]. 3. Error Handling: If the GraphQL request returns a non-200 status code (e.g., 401 Unauthorized), the command identifies the token as invalid and reports an error [1][5][2]. This is part of the command's primary function to display the authentication state of each known account [3][4]. 4. Limitations with Fine-Grained Tokens: Because fine-grained personal access tokens have different support levels for the GraphQL API compared to classic PATs or OAuth tokens, the CLI may not always be able to determine or display scopes for these specific token types, sometimes resulting in warnings [2]. For more advanced or manual interaction with GitHub's GraphQL API, the gh api graphql command is the standard interface provided by the CLI [6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,200p' src/cli/agent-driven.ts | cat -n

Repository: lidge-jun/opencodex

Length of output: 1682


Move the agent short-circuit before gh auth status in src/cli/star-prompt.ts:75-84. ghAvailable() calls gh auth status, which hits GitHub’s API; because isAgentDriven() runs later, every agent/CI ocx start still pays that network round trip even when the prompt is only being deferred. Split the installed check from the auth check, or skip the auth probe on the agent path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/star-prompt.ts` around lines 25 - 36, Avoid calling gh auth status
for agent-driven runs by moving the isAgentDriven() short-circuit ahead of the
authentication probe in the star prompt flow. Update ghAvailable() or its caller
so gh --version may still determine installation, but the network-backed auth
check is skipped when the prompt is deferred for agents.

@lidge-jun
lidge-jun merged commit 3a2b2ea into dev Jul 28, 2026
18 checks passed
@lidge-jun
lidge-jun deleted the codex/260728-ssh-loopback-gate branch July 29, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant