Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5

- package-ecosystem: swift
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5

- package-ecosystem: bundler
directory: /ios/CodexPhone
schedule:
interval: weekly
open-pull-requests-limit: 5
54 changes: 54 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: CodeQL

on:
push:
branches:
- main
pull_request:
branches:
- main
schedule:
- cron: "23 4 * * 2"

permissions:
contents: read

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ matrix.os }}
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: actions
os: ubuntu-latest
build-mode: none
- language: python
os: ubuntu-latest
build-mode: none
- language: ruby
os: ubuntu-latest
build-mode: none
- language: swift
os: macos-latest
build-mode: autobuild
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}

- name: Autobuild
if: matrix.build-mode == 'autobuild'
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1

- name: Analyze
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
15 changes: 12 additions & 3 deletions Sources/CodexAccountSwitcher/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2569,8 +2569,12 @@ enum SwitchError: LocalizedError {
}

enum CodePilotGatewayHealthProbe {
static func request() -> URLRequest {
URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!)
static func request(token: String?) -> URLRequest {
var request = URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!)
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return request
}

static func requirement(from data: Data?) -> CodePilotSetupRequirement {
Expand Down Expand Up @@ -2725,7 +2729,12 @@ struct CodePilotSetupStatus {
}

private static func gatewayHealthStatus() -> CodePilotGatewayHealthStatus {
CodePilotGatewayHealthProbe.status(from: synchronousData(for: CodePilotGatewayHealthProbe.request()))
let tokenPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".codex-account-switcher/phone-gateway-token")
let token = try? String(contentsOf: tokenPath, encoding: .utf8)
return CodePilotGatewayHealthProbe.status(
from: synchronousData(for: CodePilotGatewayHealthProbe.request(token: token))
)
}

private static func synchronousData(for request: URLRequest) -> Data? {
Expand Down
8 changes: 7 additions & 1 deletion Tests/CodexAccountSwitcherTests/SetupStatusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ final class SetupStatusTests: XCTestCase {
}

func testGatewayHealthProbeUsesPublicHealthWithoutBearerToken() {
let request = CodePilotGatewayHealthProbe.request()
let request = CodePilotGatewayHealthProbe.request(token: nil)
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))

let runningPayload = #"{"gateway":{"running":true}}"#.data(using: .utf8)
Expand All @@ -90,6 +90,12 @@ final class SetupStatusTests: XCTestCase {
XCTAssertEqual(CodePilotGatewayHealthProbe.requirement(from: Data("not json".utf8)), .gatewayStopped)
}

func testGatewayHealthProbeUsesTokenForPrivateDiagnostics() {
let request = CodePilotGatewayHealthProbe.request(token: " private-token\n")

XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer private-token")
}

func testGatewayHealthProbeReportsNotificationReadiness() {
let configuredPayload = #"{"gateway":{"running":true},"notifications":{"configured":true}}"#.data(using: .utf8)
XCTAssertEqual(
Expand Down
2 changes: 1 addition & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Recommended:

The iOS client refuses non-HTTPS gateway URLs except for `localhost`, `::1`, and syntactically valid `127.x.x.x` loopback addresses. DNS names that merely begin with `127.` are not loopback exceptions and still require HTTPS. The client also rejects gateway URLs containing user information, query strings, or fragments, and credential-bearing requests do not follow cross-origin redirects.

The gateway health response is intentionally public-safe, but it is still operational metadata. Treat the gateway URL and token as private.
The unauthenticated gateway health response contains only a liveness flag. Version, account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private.

Do not place infrastructure or connector management tokens in `phone-gateway.env`. The gateway service deliberately does not load `SUPABASE_ACCESS_TOKEN`, and gateway-launched Codex processes scrub gateway-only credentials from their environment. Configure sensitive connectors in a separately trusted local Codex session instead of exposing their bearer tokens to remotely initiated turns.

Expand Down
34 changes: 26 additions & 8 deletions gateway/codex_phone_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2303,6 +2303,13 @@ def app_server_auth_status(self) -> dict:
}

def public_health(self) -> dict:
return {
"gateway": {
"running": True,
},
}

def diagnostic_health(self) -> dict:
remote_desktop_status = {"available": False}
remote_desktop = self.remote_desktop_gateway
if remote_desktop is not None:
Expand Down Expand Up @@ -4873,7 +4880,10 @@ def run_turn_exec(


class Handler(BaseHTTPRequestHandler):
server_version = "CodexPhoneGateway/0.1"
server_version = "CodePilotGateway"

def version_string(self) -> str:
return self.server_version

def state(self) -> GatewayState:
return self.server.state
Expand All @@ -4888,9 +4898,7 @@ def end_headers(self):
super().end_headers()

def authenticate(self) -> bool:
expected = self.state().token
header = self.headers.get("authorization", "")
if secrets.compare_digest(header, f"Bearer {expected}"):
if self.is_authenticated():
return True
json_error(
self,
Expand All @@ -4901,6 +4909,11 @@ def authenticate(self) -> bool:
)
return False

def is_authenticated(self) -> bool:
expected = self.state().token
header = self.headers.get("authorization", "")
return secrets.compare_digest(header, f"Bearer {expected}")

def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
Expand All @@ -4917,21 +4930,26 @@ def do_GET(self):
self,
404,
"local_web_unavailable",
str(exc),
"Local web session not found or expired",
"Open the localhost link again from CodePilot; local web sessions expire after a short time.",
)
except Exception as exc:
except Exception:
json_error(
self,
502,
"local_web_unavailable",
str(exc),
"The selected local web server is unavailable",
"Make sure the local web server is running on the Mac, then reopen the link.",
)
return

if path == "/health" or path == "/api/health":
json_response(self, 200, self.state().public_health())
payload = (
self.state().diagnostic_health()
if self.is_authenticated()
else self.state().public_health()
)
json_response(self, 200, payload)
return

if not self.authenticate():
Expand Down
86 changes: 83 additions & 3 deletions gateway/test_codex_phone_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import subprocess
import tempfile
import threading
import urllib.error
import urllib.request
import unittest
import urllib.request
from unittest import mock
from pathlib import Path

Expand Down Expand Up @@ -1254,7 +1254,7 @@ def process_factory(arguments, **_kwargs):
finally:
gateway.JOBS.pop(job_id, None)

def test_public_health_exposes_safe_gateway_status(self):
def test_public_health_exposes_only_non_identifying_liveness(self):
with tempfile.TemporaryDirectory() as tmp:
original_switcher_home = gateway.DEFAULT_SWITCHER_HOME
switcher_home = Path(tmp) / "switcher"
Expand All @@ -1271,12 +1271,31 @@ def test_public_health_exposes_safe_gateway_status(self):
gateway.DEFAULT_SWITCHER_HOME = original_switcher_home

self.assertTrue(health["gateway"]["running"])
self.assertEqual(set(health), {"gateway"})
self.assertEqual(set(health["gateway"]), {"running"})
self.assertNotIn("secret-token", json.dumps(health))

def test_diagnostic_health_retains_authenticated_setup_status(self):
with tempfile.TemporaryDirectory() as tmp:
original_switcher_home = gateway.DEFAULT_SWITCHER_HOME
switcher_home = Path(tmp) / "switcher"
codex_home = Path(tmp) / "codex"
try:
gateway.DEFAULT_SWITCHER_HOME = switcher_home
switcher_home.mkdir()
codex_home.mkdir()
(switcher_home / "active-account.txt").write_text("main", encoding="utf-8")
state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False)

health = state.diagnostic_health()
finally:
gateway.DEFAULT_SWITCHER_HOME = original_switcher_home

self.assertEqual(health["accounts"]["active"], "main")
self.assertIn("auth", health["accounts"])
self.assertIn("notifications", health)
self.assertIn("remoteDesktop", health)
self.assertIn("localWeb", health)
self.assertNotIn("secret-token", json.dumps(health))

def test_health_endpoint_is_available_without_bearer_token(self):
with tempfile.TemporaryDirectory() as tmp:
Expand All @@ -1298,6 +1317,7 @@ def test_health_endpoint_is_available_without_bearer_token(self):
url = f"http://127.0.0.1:{server.server_address[1]}/api/health"
with urllib.request.urlopen(url, timeout=2) as response:
status = response.status
server_header = response.headers.get("Server")
payload = json.loads(response.read().decode("utf-8"))
finally:
if server is not None:
Expand All @@ -1308,9 +1328,69 @@ def test_health_endpoint_is_available_without_bearer_token(self):
gateway.DEFAULT_SWITCHER_HOME = original_switcher_home

self.assertEqual(status, 200)
self.assertEqual(server_header, "CodePilotGateway")
self.assertTrue(payload["gateway"]["running"])
self.assertEqual(set(payload), {"gateway"})
self.assertEqual(set(payload["gateway"]), {"running"})
self.assertNotIn("secret-token", json.dumps(payload))

def test_local_web_endpoint_hides_backend_failure_details(self):
state = GatewayState(
Path("/tmp/codex"),
"secret-token",
Path("/missing-codex"),
False,
local_web_fetcher=mock.Mock(side_effect=RuntimeError("private backend detail")),
)
session = state.start_local_web_session("http://localhost:3000/")
server = gateway.GatewayServer(("127.0.0.1", 0), state)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
url = f"http://127.0.0.1:{server.server_address[1]}{session['path']}"
with self.assertRaises(urllib.error.HTTPError) as raised:
urllib.request.urlopen(url, timeout=2)
payload = json.loads(raised.exception.read().decode("utf-8"))
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)

self.assertEqual(raised.exception.code, 502)
self.assertEqual(payload["error"]["message"], "The selected local web server is unavailable")
self.assertNotIn("private backend detail", json.dumps(payload))

def test_health_endpoint_returns_diagnostics_with_bearer_token(self):
with tempfile.TemporaryDirectory() as tmp:
original_switcher_home = gateway.DEFAULT_SWITCHER_HOME
switcher_home = Path(tmp) / "switcher"
codex_home = Path(tmp) / "codex"
server = None
thread = None
try:
gateway.DEFAULT_SWITCHER_HOME = switcher_home
switcher_home.mkdir()
codex_home.mkdir()
state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False)
server = gateway.GatewayServer(("127.0.0.1", 0), state)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()

url = f"http://127.0.0.1:{server.server_address[1]}/api/health"
request = urllib.request.Request(url, headers={"Authorization": "Bearer secret-token"})
with urllib.request.urlopen(request, timeout=2) as response:
payload = json.loads(response.read().decode("utf-8"))
finally:
if server is not None:
server.shutdown()
server.server_close()
if thread is not None:
thread.join(timeout=2)
gateway.DEFAULT_SWITCHER_HOME = original_switcher_home

self.assertIn("accounts", payload)
self.assertIn("notifications", payload)

def test_error_payload_has_stable_code_message_and_recovery(self):
payload = gateway.error_payload(
"local_web_invalid_target",
Expand Down
5 changes: 3 additions & 2 deletions ops/agents/escalations/security-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ Detailed security findings are intentionally not stored in the public repository

Before public launch, maintainer coordination is still required for:

- Reviewing and resolving the open GitHub secret-scanning alert through the private security channel, including revocation or rotation where applicable.
- A confidential review before any currently disabled privileged remote-access feature is re-enabled.
- A credential and edge-access architecture review for the remotely reachable gateway.
- Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration.
- Enabling repository dependency alerts and reviewing the initial results.
- Protecting the default branch with required reviews and passing checks.
- Enabling private vulnerability reporting before inviting public security reports.
- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial Dependabot, CodeQL, and secret-scanning results.
- Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials.

Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests.