# Codex Remote Control: Secure Session Continuity Plan

**Author:** Cody
**Date:** 2026-04-28
**Status:** proposed
**Repos involved:**
- `apps/wip-codex-remote-control-private`
- `wip-ldm-os-private`
- `apps/kaleidoscope-private`

## Summary

Codex Remote Control is the right product direction: keep Codex running locally on the user's Mac, let the phone drive it through WIP Computer relay infrastructure, and avoid port forwarding or moving the workspace into the cloud.

The current architecture is not ready for real work yet. It has two product blockers:

1. **Session continuity is not actually implemented.** The product promises "same session, same context, same files," but the daemon can only send prompts to in-memory Codex SDK threads it started itself. A thread id from `~/.codex/session_index.jsonl` is listed, but it is not enough to drive that existing session unless the daemon can resume or attach to it.
2. **The relay is not end-to-end encrypted.** Traffic is protected by TLS to WIP, but WIP relay code currently sees normal WebSocket JSON frames. That means WIP can inspect prompts, Codex events, command output, and errors. This violates the product expectation for private local work.

Both blockers should be fixed before dogfooding real work through the phone UI.

## Source-of-truth findings

### Existing Kaleidoscope behavior to reuse

Kaleidoscope is already the app layer for LDM OS:

- `apps/kaleidoscope-private/README.md` says Kaleidoscope owns browser/mobile UI, pairing, approval flow, device management, and agent conversation views.
- `apps/kaleidoscope-private/web/src/app/login/page.tsx` implements the Next.js passkey sign-up and sign-in surface.
- `apps/kaleidoscope-private/web/src/app/pair/page.tsx` implements phone-side pairing approval with passkey verification.
- `apps/kaleidoscope-private/web/src/app/kaleidoscope.css` defines the current product visual system for login, pairing, buttons, and status text.

Do not build a parallel auth product for Codex Remote Control. Reuse the Kaleidoscope passkey and pairing model, then add the Codex-specific remote-control state on top.

### Existing LDM OS hosted behavior to reuse

LDM OS already owns the hosted infrastructure:

- `src/hosted-mcp/server.mjs` stores passkeys and API keys.
- `/webauthn/register-options`, `/webauthn/register-verify`, `/webauthn/auth-options`, and `/webauthn/auth-verify` are already the account and passkey primitives.
- `/login?next=...` already supports the QR-to-phone flow and redirects back to the requested app route.
- `/api/codex-relay/pair-init`, `/api/codex-relay/pair-status/:id`, `/api/codex-relay/pair-complete`, `/api/codex-relay/daemon`, and `/api/codex-relay/web/:threadId` already exist as the first Codex relay skeleton.

Keep LDM OS as the relay, presence, auth, and routing layer. Change what it is allowed to see.

### Existing daemon behavior to fix

`apps/wip-codex-remote-control-private` already has the right local shape:

- `codex-daemon start|stop|status|link`
- local-only WebSocket server
- outbound relay client to `wss://wip.computer/api/codex-relay/daemon`
- MCP wrapper that can generate a remote-control URL
- Codex SDK wrapper around `startThread()` and `runStreamed()`

The gap is that `session.list` can show persisted Codex threads, but `session.send` only works for thread handles already present in the daemon's in-memory map.

## Product Requirements

### P0. WIP relay must not read user work

WIP can provide:

- passkey auth
- pairing and device ownership
- relay connection routing
- daemon presence
- route-level metadata such as user handle, daemon id, thread id, timestamps, and ciphertext byte counts

WIP must not be able to read:

- user prompts
- Codex streamed events
- command text or command output
- reasoning items
- errors generated by the local Codex turn
- session transcript content

Design target: the relay forwards opaque ciphertext envelopes after an initial E2EE handshake. TLS remains necessary, but it is not the privacy boundary.

### P0. Remote control must attach to the actual Codex session

When the phone opens a URL for a Codex thread id, the first send must target that same thread. The system must not silently create a different daemon-owned thread while displaying the original thread id.

Acceptable outcomes:

- Best: daemon resumes the existing Codex thread id and streams events from that thread.
- Acceptable fallback: if Codex SDK cannot resume a thread, the UI clearly says "start a new remote session" and uses the daemon-returned thread id from that point forward.

Not acceptable:

- list an existing thread id, open the phone UI for it, call `session.start`, ignore the temp id, then send prompts to the original id and fail with `unknown session`.

### P0. Keep Codex's local safety model intact

Remote control must not bypass the user's local Codex approval, sandbox, writable-root, or repo-guard behavior.

The phone can submit prompts and interrupt turns. It must not gain a new privilege path that local Codex would not have had.

For the first private alpha:

- restrict `workingDirectory` to an allowlisted local root or omit it entirely
- do not expose caller-controlled `skipGitRepoCheck`
- surface approval-needed and blocked-command states in the phone UI
- document that the remote phone is controlling the local Codex process under the local Codex runtime's existing policy

### P1. Browser WebSocket auth must use short-lived relay tickets

The current browser path puts the reusable `ck-` API key into a WebSocket query parameter. Replace that with a short-lived, single-use relay ticket:

1. Browser is signed in through existing Kaleidoscope passkey flow.
2. Browser calls `POST /api/codex-relay/ws-ticket` with `Authorization: Bearer ck-...`.
3. Server returns `{ ticket, expires_at }`.
4. Browser connects to `/api/codex-relay/web/:threadId?ticket=...`.
5. Server consumes the ticket once and maps it to the authenticated user.

The long-lived `ck-` key may remain in the existing Kaleidoscope session model for now, but it should not appear in WebSocket URLs.

## Target Architecture

```
[Kaleidoscope phone UI]                          [Mac codex-daemon]
       |                                                |
       | wss://wip.computer                            | wss://wip.computer
       | relay ticket, route metadata only              | daemon API key
       |                                                |
       | ciphertext frames                              | ciphertext frames
       v                                                v
                     [WIP relay / LDM OS hosted MCP]
                     - authenticates both sides
                     - routes by user + daemon + thread/session id
                     - tracks presence
                     - never decrypts Codex payloads
```

The relay remains central operationally. It is not trusted with content.

## E2EE Design

Use audited browser and Node primitives instead of a custom cipher.

Preferred MVP path:

- Use Web Crypto ECDH P-256, HKDF-SHA-256, and AES-GCM because these are available in modern browsers and Node.
- Generate a daemon remote-control identity key at `~/.codex-daemon/e2ee-key.json` on first pair.
- Store only the daemon public key and metadata on WIP.
- Generate a fresh browser ephemeral key for each phone remote-control session.
- Derive per-session keys from daemon private key + browser ephemeral public key + relay-provided transcript info.
- Encrypt every Codex `WsRequest` and `WsResponse` payload with AES-GCM.
- Include monotonically increasing send and receive counters in authenticated data to prevent replay and confused-order bugs.

Envelope shape:

```json
{
  "type": "e2ee.frame",
  "version": 1,
  "session": "route-session-id",
  "sender": "web|daemon",
  "seq": 42,
  "nonce": "base64url",
  "ciphertext": "base64url"
}
```

Plaintext inside the ciphertext is the existing Codex remote-control protocol:

```json
{ "type": "session.send", "id": "send-1", "sessionId": "...", "prompt": "..." }
```

The relay should reject plaintext Codex control messages once E2EE is enabled for a paired daemon.

### Pairing and key registration

Extend the existing Codex pair flow, do not replace it:

1. `codex-daemon link` creates or loads the daemon E2EE keypair.
2. `POST /api/codex-relay/pair-init` includes daemon public key, hostname, platform, and protocol capabilities.
3. WIP returns the existing six-character code and `/pair` URL.
4. Kaleidoscope pair UI authenticates with passkey using the existing WebAuthn flow.
5. Pair completion stores the daemon public key against the authenticated handle.
6. Daemon receives only relay credentials and non-secret confirmation from `pair-status`.

### Session handshake

1. Browser opens `/{handle}/codex-remote-control/{threadId}` through the existing `/login?next=...` path.
2. Browser calls `GET /api/codex-relay/state` or a new `GET /api/codex-relay/bootstrap/:threadId` to get daemon presence, daemon public key, and supported crypto version.
3. Browser creates an ephemeral keypair.
4. Browser connects with a one-use relay ticket.
5. Browser sends `e2ee.hello` containing its ephemeral public key.
6. Daemon derives the shared key and sends `e2ee.ready`.
7. All Codex control traffic after that is encrypted.

## Session Continuity Design

### Required daemon behavior

Add an explicit "ensure session" operation to the daemon manager:

```ts
ensureSession({ threadId, workingDirectory? }): Promise<{ sessionId, attached: boolean }>
```

Behavior:

1. If the thread id is already in the in-memory map, return it.
2. If the Codex SDK exposes `resumeThread(id)` or equivalent, resume that thread and store it in the map.
3. If resume is unavailable, return a structured error: `resume_unavailable`.
4. Do not silently call `startThread()` for a URL thread id unless the UI explicitly asked to create a new remote session.

### Required phone UI behavior

On remote-control page load:

1. Parse `{handle}` and `{threadId}` from the route.
2. Authenticate through existing Kaleidoscope passkey login if no session exists.
3. Establish E2EE.
4. Send encrypted `session.attach { threadId }`.
5. Only enable prompt input after `session.attached`.
6. If attach fails with `resume_unavailable`, show a clear action to start a new remote session. If the user accepts, send `session.start`, then replace the route/state with the real daemon-returned session id once `thread.started` arrives.

### Protocol changes

Add:

```ts
{ type: "session.attach"; id?: string; threadId: string; workingDirectory?: string }
{ type: "session.attached"; id?: string; sessionId: string; threadId: string; resumed: boolean }
{ type: "session.attach.failed"; id?: string; threadId: string; reason: "unknown_thread" | "resume_unavailable" | "auth_required" | "codex_error"; message?: string }
```

Keep existing:

- `session.start`
- `session.send`
- `session.interrupt`
- `session.close`
- `session.list`
- `session.event`

But once E2EE is on, these existing messages are ciphertext payloads, not relay-visible JSON.

## Implementation Plan

### Phase 0: Stop misleading product surfaces

Owner: `apps/wip-codex-remote-control-private`

- Update README and MCP output to say the current alpha is not E2EE and does not yet attach to arbitrary existing Codex sessions.
- Mark remote-phone dogfooding as blocked until Phase 2 and Phase 3 below pass.
- Remove or hide "same session" copy until `session.attach` works.

Done when:

- Docs match current behavior.
- No install prompt implies WIP cannot read relay payloads.
- No page implies an existing Codex thread is controllable until attach has succeeded.

### Phase 1: Align with Kaleidoscope auth and pairing

Owners: `wip-ldm-os-private`, `apps/kaleidoscope-private`

- Make Kaleidoscope's Next.js login and pair flows the canonical product UI.
- Keep LDM OS hosted MCP as the API and relay backend.
- Reuse the existing passkey endpoints. Do not add Codex-specific passkey registration.
- Reuse the QR/login `next` pattern for remote-control URLs.
- Add a Codex-specific pair variant only where needed for daemon public-key registration and relay capabilities.

Done when:

- `/login?next=/{handle}/codex-remote-control/{threadId}` works with the existing passkey flow.
- Pairing a daemon uses the same visual language and user proof flow as Kaleidoscope pairing.
- LDM OS owns API state. Kaleidoscope owns UI.

### Phase 2: Add relay tickets and E2EE

Owners: all three repos

Daemon:

- Generate and persist daemon E2EE keypair with `0600` file mode.
- Send daemon public key and crypto capabilities during `pair-init`.
- Implement E2EE handshake on relay connection.
- Decrypt incoming frames before dispatch.
- Encrypt outgoing `session.event`, `ack`, and error payloads.

LDM OS relay:

- Store daemon public key and protocol version with the paired handle.
- Add `POST /api/codex-relay/ws-ticket`.
- Replace browser `?token=ck-...` with single-use `?ticket=...`.
- Route `e2ee.hello`, `e2ee.ready`, and `e2ee.frame` opaquely.
- Reject plaintext `session.*` messages for E2EE-capable daemon pairs.
- Avoid logging ciphertext bodies, token values, or decrypted content. The relay should never have decrypted content.

Kaleidoscope:

- Fetch relay bootstrap metadata.
- Generate browser ephemeral keypair.
- Establish E2EE before enabling the composer.
- Send encrypted protocol payloads.
- Show a privacy state in internal diagnostics: `encrypted: yes`, `relay: wip.computer`, `daemon: online/offline`.

Done when:

- A relay unit test proves plaintext prompts do not pass through relay handlers.
- A browser-to-daemon integration test proves the daemon can decrypt a prompt and the browser can decrypt an event.
- Logs contain route metadata only.

### Phase 3: Implement real session attach/resume

Owner: `apps/wip-codex-remote-control-private`

- Investigate current `@openai/codex-sdk` support for thread resume.
- Add `CodexManager.ensureSession`.
- Add `session.attach` protocol handling.
- Make `session.send` refuse unknown ids with a specific attach-required error.
- Remove caller control over `skipGitRepoCheck` from remote web traffic.
- Add an allowlist or default-root policy for `workingDirectory`.

Kaleidoscope:

- On page load, send encrypted `session.attach`.
- Disable the prompt composer until attach succeeds.
- If resume is unavailable, present a clear "Start new remote session" action.
- Once a new session starts and `thread.started` arrives, update route or app state to the real thread id.

Done when:

- Opening a URL for an existing Codex thread and sending the first prompt uses that same thread.
- If attach cannot be implemented with the SDK, the UX makes the limitation explicit and never fails silently with `unknown session`.

### Phase 4: Remote safety and approval UX

Owners: daemon and Kaleidoscope

- Forward local Codex approval-needed states to the phone if the SDK exposes them.
- If approvals cannot be completed from the phone, show "action needed on Mac" with enough context.
- Keep interrupt always available during a running turn.
- Record local audit events for remote prompts, interrupts, attach attempts, and approval handoffs.
- Make remote sessions visibly tagged in daemon logs and Codex session metadata where possible.

Done when:

- A blocked command or approval prompt does not hang silently on the phone.
- The user can distinguish remote-controlled turns from local turns in local diagnostics.

### Phase 5: Multi-device hardening

Owners: LDM OS relay and Kaleidoscope

- Support more than one paired daemon per user.
- Show daemon labels in the phone UI.
- Support multiple web viewers receiving events, but restrict prompt submission to one active controller at a time.
- Add reconnect behavior that redoes E2EE session handshake after WebSocket reconnect.
- Add stale-daemon and offline states.

Done when:

- Two phones can watch the same remote session, and only the active controller can submit.
- A Wi-Fi blip recovers without exposing plaintext fallback.

## Test Plan

### Unit tests

- E2EE frame encryption/decryption round trip.
- Wrong key fails decrypt.
- Replayed sequence fails.
- Relay ticket is single-use and expires.
- Relay rejects plaintext `session.send` after E2EE is required.
- `ensureSession` returns existing in-memory session.
- `ensureSession` resumes persisted thread when supported.
- `ensureSession` returns `resume_unavailable` instead of starting an unrelated thread.

### Integration tests

- Pair daemon through existing Kaleidoscope passkey flow.
- Browser opens `/login?next=/{handle}/codex-remote-control/{threadId}` and returns to the remote-control route after sign-in.
- Browser gets a relay ticket without putting the `ck-` key in the WebSocket URL.
- Browser and daemon complete E2EE handshake through WIP relay.
- Browser sends encrypted prompt, daemon decrypts and runs Codex, browser decrypts streamed event.
- Existing thread attach works or returns a clear fallback state.
- Interrupt aborts a running turn.

### Manual alpha checks

- Use phone on cellular, Mac on Wi-Fi.
- Confirm relay logs never show prompt text.
- Confirm remote prompt follows local Codex sandbox and approval behavior.
- Confirm the UI never accepts prompt input before attach and encryption are ready.

## Open Questions

- Does the current `@openai/codex-sdk` expose a supported resume API for existing thread ids? If yes, use it. If no, decide whether to wait for SDK support or ship "new remote session" as an explicit alpha limitation.
- Should E2EE keys be per paired daemon, per browser device, or both? Recommended: daemon identity key plus browser ephemeral keys per session.
- Should the remote-control UI live fully in Kaleidoscope Next.js now, or can the LDM OS static app remain as a temporary hosted surface? Recommended: move product UI to Kaleidoscope and keep LDM OS as backend.
- What metadata is acceptable for WIP to retain? Recommended: user handle, daemon id, route id, connection times, byte counts, error categories. Not prompts, outputs, or decrypted events.

## Recommendation

Proceed with this architecture, but change the trust boundary:

- WIP Computer remains the relay and identity provider.
- Kaleidoscope remains the phone UI and passkey product layer.
- LDM OS remains the hosted API and local daemon substrate.
- Codex work payloads are end-to-end encrypted between phone and daemon.
- Existing Codex sessions are only advertised as controllable once the daemon can actually attach or resume them.

This keeps the product aligned with LDM OS: local-first, user-owned, private, and reachable from anywhere without giving the hosted relay the user's work.
