Skip to content

fix(sync): validate loopback ADE handshake before publishing a port#816

Merged
arul28 merged 4 commits into
mainfrom
ade/sync-loopback-fix-e39da9b7
Jul 15, 2026
Merged

fix(sync): validate loopback ADE handshake before publishing a port#816
arul28 merged 4 commits into
mainfrom
ade/sync-loopback-fix-e39da9b7

Conversation

@arul28

@arul28 arul28 commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Sync loopback port-collision fix (host-side)

Fixes the diagnosed 2026-07-14 incident (docs/features/sync-and-multi-device/loopback-port-collision-incident-2026-07-14.md): a foreign process binding 127.0.0.1:<sync-port> shadowed ADE's 0.0.0.0 listener with no EADDRINUSE, so Tailscale Serve + the relay tunnel silently routed to the wrong process and mobile couldn't reach the machine (only direct-LAN worked). This is the host-side complement to the Phase 1 client-side fix.

  • Loopback ADE-handshake validation before publish/accept: a 1.5s HTTP GET requiring 426 Upgrade Required runs in onListening (shared + self-owned hosts) before the port is accepted, lastPort persisted, or any route published. A shadowed port raises LoopbackShadowedError → the scan drifts to a validated-free port.
  • Atomic republish on port drift (tears down the stale tailscale serve --tcp=<old> off first, then republishes Bonjour + Tailscale together).
  • Relay guard: every relay pipe validates loopback before forwarding — no silent bridge into a foreign server; failures stay at zero tunnels + surface in status.
  • Route-health in ade sync status + ade doctor (listener / tailscale / relay: bound / validated / published / reachable / control / bridge + timestamps + reason) — enabled ≠ usable.
  • iOS keeps the Phase 1 hello-gated walker; adds 2 regressions (hello timeout + hello_error advance to the next candidate, never .connected).

Tests: 343 sync/CLI + a real macOS wildcard/foreign-loopback collision repro + relay fail-fast + typechecks + iOS sim build all green. Existing bind/scan/handoff/pairing/DPoP unchanged. Deferred hardening: dedicated private loopback listener / unix-domain socket / in-process relay adoption.

🤖 Generated with Claude Code

ADE   Open in ADE  ·  ade/sync-loopback-fix-e39da9b7 branch  ·  PR #816

Summary by CodeRabbit

  • New Features

    • Added sync route health details covering listener, Tailnet, and relay connectivity.
    • Cloud Relay status now reports connection state, active tunnels, validation, and recent failures.
    • ade doctor now checks sync readiness and highlights failing routes with recommended diagnostics.
    • iOS reconnection now tries available connection endpoints until one succeeds.
  • Bug Fixes

    • Improved handling of conflicting local listeners and prevented unverified routes from being published.
    • Relay connections now refuse unsafe or invalid sync routes with clearer error reporting.

Greptile Summary

This PR hardens sync route publishing and reconnect behavior around loopback port collisions. The main changes are:

  • Nonce-based ADE loopback validation before accepting or publishing sync ports.
  • Retry and cleanup handling when a candidate port is shadowed by another loopback listener.
  • Revalidation before LAN, Tailscale, and relay routes are refreshed or bridged.
  • Sync route-health reporting in ade sync status and ade doctor.
  • iOS reconnect fallback that keeps trying endpoints until the hello handshake succeeds.

Confidence Score: 5/5

Safe to merge based on the reviewed changes.

The changed paths consistently validate the local listener before publishing or bridging routes, and the previously reported retry and republish issues are addressed.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • Validated the general contract for the narrow vitest run and confirmed no environmental blockers.
  • Archived the full verbose vitest output log for the sync-loopback-relay run.
  • Archived the artifact note detailing the command, scope, pass/skip counts, and blockers for reviewer inspection.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
apps/ade-cli/src/cli.ts Extends doctor readiness with sync route-health checks and text output.
apps/ade-cli/src/services/sync/sharedSyncListener.ts Adds HTTP-fronted listener identity responses, loopback validation, ephemeral retry handling, and deterministic cleanup.
apps/ade-cli/src/services/sync/syncHostService.ts Validates loopback identity before publishing LAN/Tailscale routes and before refresh or re-enable paths.
apps/ade-cli/src/services/sync/syncLoopbackProbe.ts Introduces nonce-marked loopback HTTP probing and LoopbackShadowedError for unsafe listener detection.
apps/ade-cli/src/services/sync/syncService.ts Adds retry-on-shadow startup behavior and sync route-health aggregation for listener, Tailscale, and relay routes.
apps/ade-cli/src/services/sync/syncTunnelClientService.ts Validates the loopback ADE listener before opening each relay pipe and exposes bridge validation status.
apps/ios/ADE/Services/SyncService.swift Updates reconnect endpoint walking so a socket is accepted only after hello succeeds.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
  participant Host as ADE sync host
  participant Probe as Loopback probe
  participant LAN as Bonjour/Tailscale publish
  participant Relay as Cloud relay tunnel
  participant Phone as iOS client

  Host->>Host: Bind candidate sync port
  Host->>Probe: GET 127.0.0.1:port with expected nonce
  Probe-->>Host: 426 + ADE loopback marker
  alt Marker matches
    Host->>LAN: Publish validated LAN/Tailnet route
    Relay->>Probe: Validate loopback before opening pipe
    Probe-->>Relay: Current ADE listener confirmed
    Phone->>Host: Connect and complete hello
  else Shadowed or wrong listener
    Host->>Host: Close candidate and try next port
    Relay-->>Relay: Refuse bridge and record route-health failure
    Phone->>Phone: Try next endpoint candidate
  end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
  participant Host as ADE sync host
  participant Probe as Loopback probe
  participant LAN as Bonjour/Tailscale publish
  participant Relay as Cloud relay tunnel
  participant Phone as iOS client

  Host->>Host: Bind candidate sync port
  Host->>Probe: GET 127.0.0.1:port with expected nonce
  Probe-->>Host: 426 + ADE loopback marker
  alt Marker matches
    Host->>LAN: Publish validated LAN/Tailnet route
    Relay->>Probe: Validate loopback before opening pipe
    Probe-->>Relay: Current ADE listener confirmed
    Phone->>Host: Connect and complete hello
  else Shadowed or wrong listener
    Host->>Host: Close candidate and try next port
    Relay-->>Relay: Refuse bridge and record route-health failure
    Phone->>Phone: Try next endpoint candidate
  end
Loading

Comments Outside Diff (1)

  1. apps/ade-cli/src/services/sync/syncHostService.ts, line 5395-5396 (link)

    P1 Revalidates bypassed
    setDiscoveryEnabled(true) republishes Bonjour and Tailscale directly, unlike waitUntilListening and refreshLanDiscovery. If discovery is toggled back on after 127.0.0.1:<port> becomes shadowed, this path publishes the stale port without the ADE loopback handshake, reintroducing the wrong-process route this PR is meant to block. Route re-enable should go through the validated publish path and surface or log validation failure instead of publishing.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/ade-cli/src/services/sync/syncHostService.ts
    Line: 5395-5396
    
    Comment:
    **Revalidates bypassed**
    `setDiscoveryEnabled(true)` republishes Bonjour and Tailscale directly, unlike `waitUntilListening` and `refreshLanDiscovery`. If discovery is toggled back on after `127.0.0.1:<port>` becomes shadowed, this path publishes the stale port without the ADE loopback handshake, reintroducing the wrong-process route this PR is meant to block. Route re-enable should go through the validated publish path and surface or log validation failure instead of publishing.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Reviews (5): Last reviewed commit: "fix(sync): per-instance loopback identit..." | Re-trigger Greptile

@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview Jul 15, 2026 4:02am

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR validates ADE loopback ownership before sync discovery and relay forwarding, exposes listener, tailnet, and relay route health through sync status and ade doctor, and updates iOS reconnect logic to try subsequent endpoints after hello failures.

Changes

Sync route validation and health

Layer / File(s) Summary
Loopback probe contract
apps/ade-cli/src/services/sync/syncLoopbackProbe.ts
Adds loopback probe results, validation status, shadowed-listener errors, HTTP probing, and assertion helpers.
Shared listener validation and retry
apps/ade-cli/src/services/sync/sharedSyncListener.ts
Validates candidate listeners, tracks shadowed ports, cleans up failed servers, and exposes validation status.
Validated host discovery
apps/ade-cli/src/services/sync/syncHostService.ts, apps/ade-cli/src/services/sync/syncHostService.test.ts, apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts
Gates Bonjour and tailnet publication on ADE-validated loopback ownership and covers collision recovery.
Route health and relay telemetry
apps/ade-cli/src/services/sync/syncService.ts, apps/ade-cli/src/services/sync/syncTunnelClientService.ts, apps/desktop/src/shared/types/sync.ts, apps/ade-cli/src/bootstrap.ts, apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts
Tracks loopback and relay status, returns route health, wires the tunnel client into sync service, and tests relay refusal.
Doctor sync diagnostics
apps/ade-cli/src/cli.ts, apps/ade-cli/src/cli.test.ts
Adds optional sync status retrieval, readiness evaluation, snapshot fields, formatted output, and failing-route coverage.

iOS reconnect endpoint recovery

Layer / File(s) Summary
First-successful endpoint helper
apps/ios/ADE/Services/SyncService.swift
Adds an async helper that selects the first successful connection endpoint or throws the final failure.
Reconnect hello failure handling
apps/ios/ADE/Services/SyncService.swift
Retries later endpoints after retryable socket or hello failures and preserves terminal pairing invalidation behavior.
Reconnect behavior tests
apps/ios/ADETests/ADETests.swift
Covers timeout and rejected-hello progression to a later endpoint and final connected state.

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

Possibly related PRs

  • arul28/ADE#219: Related sync getStatus options used by the new doctor check.
  • arul28/ADE#705: Related cloud-tunnel plumbing extended here with loopback validation and route health.
  • arul28/ADE#813: Related iOS sync endpoint selection and reconnect behavior.

Suggested labels: desktop, ios

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.57% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary sync change: validating the loopback ADE handshake before publishing a port.
✨ 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 ade/sync-loopback-fix-e39da9b7

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.

@arul28

arul28 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@copilot review but do not make fixes

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/ade-cli/src/services/sync/syncHostService.ts (1)

5295-5306: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add coverage for the shared-listener mismatch throw path. The current tests cover the self-owned listener rejection and the shared-listener success case, but not the waitUntilListening() branch that throws when sharedListener.getLoopbackValidationStatus() doesn’t match the returned port.

🤖 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 `@apps/ade-cli/src/services/sync/syncHostService.ts` around lines 5295 - 5306,
Add a test covering the shared-listener branch in waitUntilListening where
getLoopbackValidationStatus returns an unvalidated status or a mismatched port.
Assert that waitUntilListening rejects with the ADE-validation error and that
publishValidatedDiscovery is not invoked, while preserving the existing
shared-listener success and self-owned listener tests.

Source: Path instructions

🧹 Nitpick comments (2)
apps/ade-cli/src/services/sync/syncTunnelClientService.ts (1)

206-228: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Confirm the relay/phone gets a timely rejection when loopback validation fails during openTunnel.

On assertAdeLoopbackListener failure, the function only logs sync_tunnel.loopback_validation_failed and returns — it never opens (or opens-then-closes) the pipe socket back to the relay for this connectionId. Unless the relay worker has its own short timeout for un-piped connectionIds, the requesting phone could be left waiting on its own client-side timeout instead of getting a fast, explicit rejection, which seems to run counter to the PR's stated "relay fail-fast coverage" goal.

Consider having the host briefly open then immediately close the pipe with a specific close code/reason (or send an explicit control-channel rejection) so the relay can propagate a fast failure to the caller.

🤖 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 `@apps/ade-cli/src/services/sync/syncTunnelClientService.ts` around lines 206 -
228, The openTunnel failure path after assertAdeLoopbackListener rejects without
notifying the relay. Update openTunnel to briefly open and immediately close the
pipe for the current connectionId using the established failure close
code/reason mechanism, or send the existing explicit control-channel rejection,
while preserving the current logging and return behavior.
apps/ade-cli/src/services/sync/sharedSyncListener.ts (1)

327-392: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared loopback-validation-status update into a helper. Both sites independently rebuild the identical SyncLoopbackValidationStatus success/failure shape right after assertAdeLoopbackListener, which is one root cause split across two files.

  • apps/ade-cli/src/services/sync/sharedSyncListener.ts#L327-L392: replace the inline loopbackValidationStatus = {...} construction in bindOnce's onListening handler with a call to a shared helper (e.g. applyLoopbackProbeOutcome(current, port, resultOrError) exported from syncLoopbackProbe.ts).
  • apps/ade-cli/src/services/sync/syncHostService.ts#L2845-L2885: replace the inline construction inside validateListeningPort with the same shared helper.
♻️ Suggested shared helper (syncLoopbackProbe.ts)
export function applyLoopbackProbeSuccess(
  current: SyncLoopbackValidationStatus,
  port: number,
  result: SyncLoopbackProbeResult,
): SyncLoopbackValidationStatus {
  return {
    port,
    loopbackAdeValidated: true,
    lastFailureAt: current.lastFailureAt,
    reason: null,
    lastSuccessAt: result.checkedAt,
  };
}

export function applyLoopbackShadowFailure(
  current: SyncLoopbackValidationStatus,
  error: LoopbackShadowedError,
): SyncLoopbackValidationStatus {
  return {
    port: error.port,
    loopbackAdeValidated: false,
    lastFailureAt: error.failedAt,
    reason: error.message,
    lastSuccessAt: current.lastSuccessAt,
  };
}
🤖 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 `@apps/ade-cli/src/services/sync/sharedSyncListener.ts` around lines 327 - 392,
Extract the duplicated loopback validation status construction into shared
helpers in syncLoopbackProbe.ts, covering successful probes and
LoopbackShadowedError failures while preserving the existing current-status
fields. In apps/ade-cli/src/services/sync/sharedSyncListener.ts lines 327-392,
replace bindOnce’s inline assignments in onListening with the shared helper;
apply the same change in apps/ade-cli/src/services/sync/syncHostService.ts lines
2845-2885 within validateListeningPort, with no direct changes needed elsewhere.
🤖 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 `@apps/ade-cli/src/services/sync/syncService.ts`:
- Around line 1228-1271: Update the relayReason computation in the route-health
construction to treat relayBridgeValidated === false as unhealthy after relay
control connectivity is confirmed. Add a branch that reports the bridge as not
validated before falling through to tunnelStatus.lastError, so relay.reason
remains non-null until the current sync port has been validated; preserve the
existing disabled, loopback-invalid, unavailable-status, and
disconnected-control branches.

---

Outside diff comments:
In `@apps/ade-cli/src/services/sync/syncHostService.ts`:
- Around line 5295-5306: Add a test covering the shared-listener branch in
waitUntilListening where getLoopbackValidationStatus returns an unvalidated
status or a mismatched port. Assert that waitUntilListening rejects with the
ADE-validation error and that publishValidatedDiscovery is not invoked, while
preserving the existing shared-listener success and self-owned listener tests.

---

Nitpick comments:
In `@apps/ade-cli/src/services/sync/sharedSyncListener.ts`:
- Around line 327-392: Extract the duplicated loopback validation status
construction into shared helpers in syncLoopbackProbe.ts, covering successful
probes and LoopbackShadowedError failures while preserving the existing
current-status fields. In apps/ade-cli/src/services/sync/sharedSyncListener.ts
lines 327-392, replace bindOnce’s inline assignments in onListening with the
shared helper; apply the same change in
apps/ade-cli/src/services/sync/syncHostService.ts lines 2845-2885 within
validateListeningPort, with no direct changes needed elsewhere.

In `@apps/ade-cli/src/services/sync/syncTunnelClientService.ts`:
- Around line 206-228: The openTunnel failure path after
assertAdeLoopbackListener rejects without notifying the relay. Update openTunnel
to briefly open and immediately close the pipe for the current connectionId
using the established failure close code/reason mechanism, or send the existing
explicit control-channel rejection, while preserving the current logging and
return behavior.
🪄 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: CHILL

Plan: Pro

Run ID: b1b560e3-1105-46ab-b383-48520312bf72

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb8ce8 and 8b0ba382f28eb3b0fc5738e5fe7bc5e24e564e04.

📒 Files selected for processing (14)
  • apps/ade-cli/src/bootstrap.ts
  • apps/ade-cli/src/cli.test.ts
  • apps/ade-cli/src/cli.ts
  • apps/ade-cli/src/services/sync/sharedSyncListener.ts
  • apps/ade-cli/src/services/sync/syncHostService.test.ts
  • apps/ade-cli/src/services/sync/syncHostService.ts
  • apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts
  • apps/ade-cli/src/services/sync/syncLoopbackProbe.ts
  • apps/ade-cli/src/services/sync/syncService.ts
  • apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts
  • apps/ade-cli/src/services/sync/syncTunnelClientService.ts
  • apps/desktop/src/shared/types/sync.ts
  • apps/ios/ADE/Services/SyncService.swift
  • apps/ios/ADETests/ADETests.swift

Comment thread apps/ade-cli/src/services/sync/syncService.ts
@arul28

arul28 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@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

https://github.com/arul28/ADE/blob/d3d96139b695c9e119ba12e4d4e4e5f9d0f2e679/apps/ade-cli/src/services/sync/syncHostService.ts#L5395-L5396
P1 Badge Revalidate before republishing discovery routes

When discovery is toggled back on for an already-bound host, this path calls the raw Bonjour and Tailscale publishers without the new ADE loopback handshake. If 127.0.0.1:<port> becomes shadowed while discovery is disabled, re-enabling discovery republishes the stale port and routes phones/Tailscale Serve back to the foreign listener, which is the collision this change is meant to block. Route re-enable should go through the validated publish path and surface the validation failure instead of publishing.

ℹ️ 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 apps/ade-cli/src/services/sync/syncHostService.ts
arul28 and others added 2 commits July 14, 2026 22:07
Host-side fix for the 2026-07-14 loopback port-collision incident: a
foreign 127.0.0.1:<port> listener shadowed ADE's 0.0.0.0 bind with no
EADDRINUSE, routing Tailscale + relay to the wrong process. A 426 loopback
probe now validates each port before it is accepted/published; shadowed
ports drift to a validated-free port with atomic route republish; relay
validates before forwarding; ade sync status + doctor report per-route
health. 343 tests + a real macOS collision repro green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate relayReason on relayBridgeValidated so ade doctor / sync status surface a
stale relay bridge (control-connected but not revalidated against the current
sync port) instead of reporting the route healthy. Mirrors the tailscaleReason
gating so the diagnostics this PR adds don't hide a stale-but-connected bridge.

Adds a producer-level route-health regression test (relay bridge not validated
=> non-null relay.reason) and coverage for the shared-listener
waitUntilListening ADE-validation throw path.

Addresses CodeRabbit review on #816.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@arul28
arul28 force-pushed the ade/sync-loopback-fix-e39da9b7 branch from d3d9613 to 3027149 Compare July 15, 2026 02:08
@arul28

arul28 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@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: 302714933a

ℹ️ 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 on lines +67 to +68
const ok = statusCode === 426
&& (statusMessage == null || statusMessage.toLowerCase() === "upgrade required");

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 Require an ADE-specific loopback probe

This treats any listener that returns 426 Upgrade Required as ADE, but a plain ws WebSocketServer returns the same response to a non-upgrade HTTP GET. If the colliding 127.0.0.1:<port> process is another WebSocket service, startup and relay validation will pass and publish/bridge to the wrong process again; the probe needs an ADE-specific WebSocket exchange or other ADE-only signal rather than only the generic 426 status.

Useful? React with 👍 / 👎.

Comment on lines +2900 to +2901
publishLanDiscovery(port, { force: options?.forceLan });
publishTailnetDiscovery(port, { force: options?.forceTailnet });

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 Revalidate before publishing refreshed routes

refreshLanDiscovery({ forceTailnet: true }) now calls this helper directly, but the helper republishes Bonjour/Tailscale without re-running assertAdeLoopbackListener; setDiscoveryEnabled(true) also still publishes directly. If a loopback-specific process shadows the already-bound port after startup, a refresh or discovery re-enable can advertise the stale port using the old successful validation, reintroducing the wrong-process route this change is meant to prevent.

Useful? React with 👍 / 👎.

Comment thread apps/ade-cli/src/services/sync/sharedSyncListener.ts Outdated
…on gaps

Addresses three P1 review findings (Codex x2, Greptile) on the loopback
port-collision hardening. All security/reliability in the sync host path.

1) Probe was not ADE-specific (Codex P1). probeAdeLoopbackListener treated any
   HTTP 426 as ADE, but a bare 'ws' server (a stale/foreign WebSocket process,
   incl. a second ADE instance) also answers plain GETs with 426 — so the
   'ADE handshake' could be satisfied by any WebSocket listener. Both ADE sync
   server sites (syncHostService self-owned host; sharedSyncListener bindOnce)
   now front their WebSocketServer with an explicit http.Server whose non-upgrade
   426 response carries a marker header 'x-ade-sync-loopback: 1'; the probe
   requires status 426 AND that marker. The WS upgrade path is unchanged
   (WebSocketServer constructed with { server }). NOTE: the 426 deliberately omits
   Upgrade/Connection headers — sending them on a non-upgrade response corrupts
   Node's keep-alive state machine (subsequent requests 400).

2) Refresh/re-enable skipped revalidation (Codex P1). refreshLanDiscovery and
   setDiscoveryEnabled(true) republished Bonjour/Tailscale without re-running the
   loopback check, so a shadow that appeared AFTER startup could re-advertise a
   stale port. Both now validateListeningPort(port,{force:true}) before
   (re)publishing and fail closed (skip publish, mark route unvalidated) on a
   shadow.

3) Ephemeral (port 0) shadow could not retry (Greptile P1). A loopback-shadowed
   bind(0) exhausted the single-entry attempt plan and failed instead of asking
   the OS for a fresh port. Port 0 now gets bounded PREFERRED_PORT_BIND_ATTEMPTS
   re-binds (each a fresh OS port), tracks the RESOLVED shadowed port (not literal
   0), and skips the fixed-port retry delay; loopback-shadow errors are now
   retryable. Mirrored in the syncService self-owned startup loop.

Incidental: fronting ws with an external http.Server changes close() semantics
(ws.close() drains clients and does not close the http server), so listener
close / candidate cleanup / host dispose now terminate clients and close the
http server (closeAllConnections) to free the port deterministically.

Tests: new syncLoopbackProbe.test.ts (marker required); collision tests for a
markerless-426 foreign listener rejection and ephemeral-shadow retry/termination;
host tests for refresh/re-enable revalidation. tsc clean; sync suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@arul28

arul28 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

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

ℹ️ 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 on lines +109 to +114
const hasAdeMarker = markerValue === SYNC_LOOPBACK_ADE_MARKER_VALUE;
const statusOk = statusCode === 426
&& (statusMessage == null || statusMessage.toLowerCase() === "upgrade required");
// A foreign/stale bare `ws` server also returns 426, so the status is
// necessary but not sufficient — the ADE marker must be present too.
const ok = statusOk && hasAdeMarker;

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 Require a per-listener loopback proof

When the loopback shadow is another ADE process, or any local listener that copies this public x-ade-sync-loopback: 1 marker, this probe accepts the shadow as the current listener even though Tailscale/relay traffic to 127.0.0.1:<port> will go to that other process. The check needs an unforgeable per-listener value or challenge tied to the server instance before publishing the port.

Useful? React with 👍 / 👎.

Comment on lines +5395 to +5399
loopbackValidationStatus = sharedListener!.getLoopbackValidationStatus();
if (!loopbackValidationStatus.loopbackAdeValidated || loopbackValidationStatus.port !== port) {
throw new Error(`The shared sync listener on 127.0.0.1:${port} was not ADE-validated.`);
}
await publishValidatedDiscovery(port);

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 Re-probe the shared listener before publishing

On a hosted-project switch with the shared listener, this branch trusts the cached validation status from the original bind and immediately republishes discovery. If a more-specific 127.0.0.1:<port> shadow appears after that first validation, the self-owned refresh paths would catch it with a forced probe, but this shared-listener startup path reuses the stale success and can advertise a Tailscale/relay route that now targets the shadow.

Useful? React with 👍 / 👎.

…ff re-probe

Addresses Codex P1 (require a per-listener loopback proof) and P2 (re-probe the
shared listener before publishing) on PR #816.

P1: the static public marker 'x-ade-sync-loopback: 1' was forgeable — a stale or
second ADE process on 127.0.0.1:<port> emits the same constant, so the probe
could not tell OUR listener from another ADE instance shadowing the port. Now
each listener generates a fresh 128-bit nonce at construction
(generateLoopbackNonce), stamps it as the 426 identity header, and every
IN-PROCESS validator compares the probed value against the exact nonce it holds.
A shadow is a different process and cannot present this instance's nonce.
Threaded through: sharedSyncListener (one nonce/instance, getExpectedLoopbackNonce),
self-owned host server (reuses the shared listener's nonce when injected, else its
own), the syncService startup loop (via host construction), and the relay tunnel
client (nonce provider wired in bootstrap.ts). The relay path fails closed when the
identity is unavailable, and relayBridgeValidated now also requires the nonce to
still match the current listener (a restarted listener with a new nonce invalidates
a stale bridge).

P2: SharedSyncListener.revalidateLoopback() force re-probes the current bound port
with the instance nonce; the shared-listener branch of waitUntilListening now calls
it before republishing LAN/Tailscale discovery, so a shadow that appears AFTER the
original bind is caught on a project-host handoff (throws before publish; fails
closed).

Tests: syncLoopbackProbe.test.ts proves a different-instance ADE listener (and the
legacy static '1', and a markerless 426) are rejected while the own-identity passes;
collision/host tests cover the handoff re-probe and thread the nonce through the
existing shadow/retry/refresh fixtures. tsc clean; sync suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@arul28

arul28 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

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

ℹ️ 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({ ...result, port, checkedAt });
};
const request = http.get({
host: "127.0.0.1",

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 Support configured loopback bind hosts

When ADE_SYNC_BIND_HOST is set to ::1 (or to localhost on a system that resolves it to IPv6), the sync listener can bind successfully on IPv6 loopback, but this new validation always probes IPv4 127.0.0.1. In that configuration waitUntilListening/ensureListening will report the ADE listener as shadowed and refuse to publish a port even though the listener is healthy; the probe should use the configured loopback address or try both IPv4 and IPv6 loopback routes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Working as intended for the supported configs — deliberately not changing this.

The probe validates 127.0.0.1 because that is the exact address both published routes forward to: Tailscale Serve uses tcp://127.0.0.1:${port} (syncHostService.ts:2738) and the relay bridge uses ws://127.0.0.1:${port} (syncTunnelClientService.ts:251). The default bind host is 0.0.0.0 (IPv4 wildcard), which 127.0.0.1 reaches.

If ADE_SYNC_BIND_HOST=::1 (IPv6-only), the listener is not reachable at 127.0.0.1 where BOTH routes forward — so the listener is already unreachable end-to-end regardless of the probe, and refusing to publish is correct fail-closed behavior (publishing would advertise a 127.0.0.1 route that nothing answers). Making only the probe try ::1 would desync it from the route targets and could re-open the shadow gap for a mixed IPv4/IPv6 host. ::1 is not a supported bind mode today (only ADE_SYNC_BIND_HOST=127.0.0.1 is documented). Full IPv6-loopback support (bind + probe + Tailscale Serve target + relay forward, consistently) is a separate enhancement, tracked as a follow-up rather than fixed here.

@arul28
arul28 merged commit de10485 into main Jul 15, 2026
32 checks passed
@arul28

arul28 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Ship complete — merged. Coordinator note on the loopback security-protocol change made during ship:

What changed in the loopback validation protocol (P1 spoofing fix):

  • The loopback probe no longer trusts a static/public marker. Each sync listener now generates a fresh 128-bit per-instance nonce at construction (generateLoopbackNonce), stamps it as the x-ade-sync-loopback header on its non-upgrade 426 response, and every in-process validator compares the probed value against the exact nonce it holds. A shadow is a different process and cannot present this instance's nonce — so a stale/second ADE instance shadowing 127.0.0.1: is now correctly rejected (not just foreign non-ADE listeners).
  • Threaded through: sharedSyncListener (one nonce/instance + getExpectedLoopbackNonce/revalidateLoopback), the self-owned host server (reuses the shared listener's nonce when injected), the syncService startup loop (via host construction), and the relay tunnel client (nonce provider wired in bootstrap.ts). The relay path fails closed when identity is unavailable, and relayBridgeValidated now also requires the nonce to still match the current listener.
  • P2: revalidateLoopback() force re-probes before the shared-listener handoff republishes discovery (fails closed on a post-bind shadow).

Findings resolved (mapping to your three classes):

  • loopback probe spoofing → per-instance nonce (above) + earlier static-marker + revalidation + port-0 retry fixes.
  • relay-only bucketingrelayReason now gates on relayBridgeValidated so ade doctor/sync status don't report a stale relay bridge as healthy.
  • account routing → no account-routing finding surfaced on this PR (it doesn't touch that surface; that's feat(sync): account attestation auth branch + pairPeerViaAccount #817).

Codex's final P2 (IPv6 ::1 bind host) was left as working-as-intended: the probe deliberately validates 127.0.0.1 because that's the exact target of BOTH published routes (Tailscale Serve tcp://127.0.0.1, relay ws://127.0.0.1); a ::1-only bind is unreachable via those routes regardless. Full IPv6-loopback support (bind+probe+serve+relay consistently) is a possible follow-up. Rationale posted inline on the thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant