Skip to content

feat/issue 21 connection approval tui#33

Open
guysmoilov wants to merge 7 commits intomainfrom
feat/issue-21-connection-approval-tui
Open

feat/issue 21 connection approval tui#33
guysmoilov wants to merge 7 commits intomainfrom
feat/issue-21-connection-approval-tui

Conversation

@guysmoilov
Copy link
Member

@guysmoilov guysmoilov commented Feb 14, 2026

Implementation Details

Dependencies & Tooling

  • Added geoip-lite for IP-based geolocation resolution of pending connections
  • Added ink for the server-side TUI rendering
  • Added jose for JWT cryptographic operations (HS256)
  • Downgraded React from v19.2.4 to v18.3.1 (with corresponding type updates)
  • Extended TypeScript backend compilation to include TSX files

Core Services & Types

  • ApprovalService: New EventEmitter-based class managing pending connection lifecycle with JWT issuance
    • Tracks pending connections with auto-generated IDs and challenge codes
    • Emits events: pending-added, approved, denied, pending-removed
    • Provides JWT signing/verification with configurable expiration (HS256)
    • Implements detailed JWT validation with specific error reasons
  • Protocol Types: Extended messaging between client/server with new auth states:
    • auth_pending (challenge code)
    • auth_approved (JWT and clientId)
    • auth_denied (reason)

Server-Side Changes

  • CLI Integration: New flags --no-approve (disable approval) and --jwt-lifetime (configure JWT expiration, default 86400s)
  • WebSocket Authentication Flow:
    • JWT-first path for established connections
    • Fallback to password authentication with optional pending approval when credentials invalid
    • Socket cleanup removes any pending approvals on disconnect
  • API Enhancement: /api/config endpoint now exposes approvalEnabled flag
  • Geolocation Tracking: Resolves client IP to location string for TUI display (with privacy notes)

Frontend Integration

  • JWT Storage: Client-side JWT stored in ref for automatic session reattachment
  • Approval Overlay: Displays challenge code and enables fallback to manual password entry during pending approval
  • Auth Payload Construction: Both control and terminal sockets now conditionally include JWT in auth messages
  • Styling: Added .approval-card, .challenge-code, and spinner animation CSS for UI presentation

Testing

  • Unit Tests: 14 tests for ApprovalService (state management, JWT workflows, event emission); 3 tests for geoip resolver (private IP handling, public IP resolution)
  • Integration Tests: 15 comprehensive scenarios covering approval flows, JWT validation/reissuance, simultaneous pending connections, reconnection behavior, terminal JWT authentication, socket cleanup, /api/config reflection, and --no-approve behavior

Configuration

  • RuntimeConfig: Extended with approvalEnabled and jwtLifetimeSecs fields
  • ControlContext: Added pendingApprovalId field to track approval state per connection
  • DataContext: Added authInProgress flag to guard terminal authentication during pending approvals

@coderabbitai
Copy link

coderabbitai bot commented Feb 14, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements a connection-approval workflow enabling users to approve or deny pending client connections via challenge codes and JWT-based authentication. It adds an ApprovalService to manage pending connections, protocol messages for approval state transitions, backend CLI options, a terminal UI for approvals, and frontend support for JWT-based authentication and approval overlays.

Changes

Cohort / File(s) Summary
Approval Service Infrastructure
src/backend/auth/approval-service.ts, src/backend/auth/approval-types.ts, src/backend/util/geoip.ts
New ApprovalService class managing pending connections with challenge codes and JWT operations; PendingConnection and ApprovalServiceOptions interfaces defining connection metadata and service configuration; geoip utility for IP-to-location resolution.
Backend Runtime Configuration
package.json, src/backend/cli.ts, src/backend/config.ts
Added geoip-lite, ink, and jose dependencies; downgraded React from v19 to v18; new CLI flags (no-approve, jwt-lifetime) for approval control; RuntimeConfig extended with approvalEnabled and jwtLifetimeSecs fields.
Server WebSocket Handler
src/backend/server.ts
Integrated ApprovalService into connection handling; JWT verification path added before password auth; pending approval flow with challenge codes; auth events (approved/denied) trigger reconnections; cleanup on socket close; /api/config response includes approvalEnabled flag.
Protocol Definitions
src/backend/types/protocol.ts, src/frontend/types/protocol.ts
Extended ControlClientMessage auth variant with optional jwt field; added auth_pending, auth_approved, and auth_denied message types to ControlServerMessage union.
Frontend Authentication & UI
src/frontend/App.tsx, src/frontend/styles/app.css
ServerConfig interface extended with approvalEnabled; socket functions (openControlSocket, openTerminalSocket) accept optional JWT parameter; message handlers for auth_pending/approved/denied state transitions; approval overlay component displaying challenge code; new CSS styles for approval card, challenge code display, and spinner animation.
Terminal User Interface
src/backend/tui/approval-tui.tsx
New Ink-based TUI component for displaying and managing pending approval requests with keyboard navigation (up/down arrows), approval (a), denial (d), and quit (q) actions; displays connection metadata including challenge code, IP, geolocation, and user agent.
TypeScript Configuration
tsconfig.backend.json
Extended to compile TSX files in addition to TS files in backend directory.
Test Coverage
tests/backend/approval-service.test.ts, tests/backend/geoip.test.ts, tests/e2e/harness/test-server.ts, tests/integration/server.test.ts, tests/integration/approval-flow.test.ts
Comprehensive test suite for ApprovalService (JWT workflows, event emission, state management); geoip resolution validation; integration tests for approval flow covering passwordless connections, pending approval, approval/denial sequences, JWT reconnection, and disabled approval mode; test configuration updated with approvalEnabled and jwtLifetimeSecs fields.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Server
    participant ApprovalService
    participant Approver

    Client->>Server: connect (no password, optional JWT)
    Server->>ApprovalService: addPending(connection metadata)
    ApprovalService->>Server: emit pending-added
    Server->>Client: auth_pending (challenge code)
    
    Approver->>ApprovalService: view pending connections
    Note over Approver: Review connection details
    
    alt Approve
        Approver->>ApprovalService: approve(id)
        ApprovalService->>ApprovalService: generate JWT
        ApprovalService->>Server: emit approved
        Server->>Client: auth_approved (JWT, clientId)
        Client->>Client: store JWT
        Client->>Server: reconnect with JWT
        Server->>Client: auth_ok
    else Deny
        Approver->>ApprovalService: deny(id)
        ApprovalService->>Server: emit denied
        Server->>Client: auth_denied (reason)
        Client->>Client: clear pending state
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/issue-21-connection-approval-tui

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 and usage tips.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 15, 2026

UI Screenshots

Auto-captured from commit c1c1a55

Screenshots are available as a build artifact.

Files captured: drawer-open.png, main-view.png

Download the ui-screenshots artifact from the Actions run to view full-resolution images.

@qodo-code-review
Copy link

qodo-code-review bot commented Feb 15, 2026

CI Feedback 🧐

(Feedback updated until commit d51408a)

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: browser-e2e

Failed stage: Run Browser E2E Tests [❌]

Failed test name: toolbar layout: unified rows with balanced button distribution

Failure summary:

The action failed because a Playwright E2E test assertion failed in
tests/e2e/toolbar-layout.spec.ts.
- Failing test: toolbar layout: unified rows with balanced button
distribution (reported at tests/e2e/toolbar-layout.spec.ts:14:1).
- Failure location:
tests/e2e/toolbar-layout.spec.ts:25:22, where expect(row1Labels).toEqual([...]) did not match.
-
Mismatch: the UI rendered the label up but the test expected the arrow symbol in the Row 1 button
labels ("↑" expected, "up" received).
- This test failure caused the test run to exit non-zero,
leading to Process completed with exit code 1.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

457:  - Using dynamic import() to code-split the application
458:  - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
459:  - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.�[39m
460:  �[32m✓ built in 1.78s�[39m
461:  > tmux-mobile@0.0.8 build:backend
462:  > tsc -p tsconfig.backend.json
463:  Running 9 tests using 1 worker
464:  ········Row 1 buttons: [
465:  �[32m'Esc'�[39m, �[32m'Ctrl'�[39m, �[32m'Alt'�[39m,
466:  �[32m'Cmd'�[39m, �[32m'Meta'�[39m, �[32m'/'�[39m,
467:  �[32m'@'�[39m,   �[32m'Hm'�[39m,   �[32m'up'�[39m,
468:  �[32m'Ed'�[39m
469:  ]
470:  F
471:  1) tests/e2e/toolbar-layout.spec.ts:14:1 › toolbar layout: unified rows with balanced button distribution 
472:  Error: �[2mexpect(�[22m�[31mreceived�[39m�[2m).�[22mtoEqual�[2m(�[22m�[32mexpected�[39m�[2m) // deep equality�[22m
473:  �[32m- Expected  - 1�[39m
...

479:  �[2m    "@",�[22m
480:  �[2m    "Hm",�[22m
481:  �[32m-   "↑",�[39m
482:  �[31m+   "up",�[39m
483:  �[2m    "Ed",�[22m
484:  �[2m  ]�[22m
485:  23 |   console.log('Row 1 buttons:', row1Labels);
486:  24 |   expect(row1Buttons.length).toBe(10);
487:  > 25 |   expect(row1Labels).toEqual(['Esc', 'Ctrl', 'Alt', 'Cmd', 'Meta', '/', '@', 'Hm', '↑', 'Ed']);
488:  |                      ^
489:  26 |
490:  27 |   // Row 2: ^C, ^B, ^R, Sft, Tab, Enter, ..., ←, ↓, → (10 buttons)
491:  28 |   const row2Buttons = await mainRows[1].locator('button').all();
492:  at /home/runner/work/tmux-mobile/tmux-mobile/tests/e2e/toolbar-layout.spec.ts:25:22
493:  attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
494:  test-results/toolbar-layout-toolbar-lay-3ec54-alanced-button-distribution/test-failed-1.png
495:  ────────────────────────────────────────────────────────────────────────────────────────────────
496:  attachment #2: video (video/webm) ──────────────────────────────────────────────────────────────
497:  test-results/toolbar-layout-toolbar-lay-3ec54-alanced-button-distribution/video.webm
498:  ────────────────────────────────────────────────────────────────────────────────────────────────
499:  Error Context: test-results/toolbar-layout-toolbar-lay-3ec54-alanced-button-distribution/error-context.md
500:  attachment #4: trace (application/zip) ─────────────────────────────────────────────────────────
501:  test-results/toolbar-layout-toolbar-lay-3ec54-alanced-button-distribution/trace.zip
502:  Usage:
503:  npx playwright show-trace test-results/toolbar-layout-toolbar-lay-3ec54-alanced-button-distribution/trace.zip
504:  ────────────────────────────────────────────────────────────────────────────────────────────────
505:  1 failed
506:  tests/e2e/toolbar-layout.spec.ts:14:1 › toolbar layout: unified rows with balanced button distribution 
507:  8 passed (8.0s)
508:  ##[error]Process completed with exit code 1.
509:  Post job cleanup.

@guysmoilov guysmoilov force-pushed the feat/issue-21-connection-approval-tui branch from cd43792 to 7474844 Compare February 17, 2026 21:57
@guysmoilov guysmoilov marked this pull request as ready for review February 17, 2026 21:57
@guysmoilov guysmoilov force-pushed the feat/issue-21-connection-approval-tui branch from 7474844 to 439d7c7 Compare February 17, 2026 21:58
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

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

⚠️ Outside diff range comments (1)
src/frontend/App.tsx (1)

294-306: ⚠️ Potential issue | 🟡 Minor

Forward jwtValue to the terminal socket in the auth_ok handler for independent endpoint authentication.

The control and terminal WebSocket endpoints must authenticate independently. Currently, line 305 calls openTerminalSocket(passwordValue) without forwarding jwtValue. If the control socket authenticated via stored JWT and the server sends auth_ok (instead of the typical auth_approved), the terminal socket would lack the JWT and fail authentication despite the control socket succeeding.

While the server currently sends auth_approved with JWT for JWT-based auth, this defensive pattern ensures terminal endpoint authentication remains independent and resilient to any auth response variation.

Proposed fix
         case "auth_ok":
           setErrorMessage("");
           setPasswordErrorMessage("");
           setAuthReady(true);
           setNeedsPasswordInput(false);
           setPendingApproval(false);
           if (message.requiresPassword && passwordValue) {
             localStorage.setItem("tmux-mobile-password", passwordValue);
           } else {
             localStorage.removeItem("tmux-mobile-password");
           }
-          openTerminalSocket(passwordValue);
+          openTerminalSocket(passwordValue, jwtValue);
           return;
🧹 Nitpick comments (9)
src/backend/config.ts (1)

11-12: jwtLifetimeSecs is required even when approvalEnabled: false.

jwtLifetimeSecs has no effect when approvalEnabled is false, yet all RuntimeConfig consumers must supply it. Making it optional (or grouping the two fields into an optional approvalConfig sub-object) would make the relationship between the fields explicit and remove dead config from non-approval setups.

♻️ Proposed refactor
 export interface RuntimeConfig {
   port: number;
   host: string;
   password?: string;
   tunnel: boolean;
   defaultSession: string;
   scrollbackLines: number;
   pollIntervalMs: number;
   token: string;
   frontendDir: string;
-  approvalEnabled: boolean;
-  jwtLifetimeSecs: number;
+  approvalEnabled: boolean;
+  jwtLifetimeSecs?: number;  // only meaningful when approvalEnabled = true
 }
package.json (1)

55-64: React 19 → 18 downgrade is driven by ink 5.x's incompatibility with React 19.

ink v5.x requires React 18. The downgrade from React 19 to 18 is the correct fix for the chosen ink version. However, ink@^6 requires React >=19.0.0 and was developed to address React 19 compatibility. If the project wants to stay on React 19 long-term, upgrading to ink@^6 would unblock that—but this would also require upgrading React back to 19.

The current pairing (ink 5 + React 18) is valid and requires no changes, but it's worth tracking if React 19 compatibility becomes important later.

src/backend/auth/approval-service.ts (2)

19-19: Challenge code character set includes non-alphanumeric symbols.

randomToken(3) produces base64url output which includes - and _. After toUpperCase(), the 4-char code could contain characters like _ or - (e.g., A_B-), which may look odd in a user-facing approval UI. Consider restricting the alphabet to alphanumeric only for a cleaner UX.

🔧 Proposed fix
-    const challengeCode = randomToken(3).slice(0, 4).toUpperCase();
+    const challengeCode = randomToken(6)
+      .replace(/[^A-Za-z0-9]/g, "")
+      .slice(0, 4)
+      .toUpperCase();

63-71: verifyJwt discards the token payload — caller cannot identify the authenticated client.

The server will likely need the clientId claim from a verified JWT (e.g., on reconnection). Currently the caller gets only { valid: true } with no way to know who the token belongs to without decoding it again separately.

♻️ Proposed fix
-  public async verifyJwt(token: string): Promise<{ valid: boolean; reason?: string }> {
+  public async verifyJwt(token: string): Promise<{ valid: boolean; reason?: string; clientId?: string }> {
     try {
-      await jwtVerify(token, this.jwtSecret);
-      return { valid: true };
+      const { payload } = await jwtVerify(token, this.jwtSecret);
+      return { valid: true, clientId: payload.clientId as string };
     } catch (error) {
       const reason = error instanceof Error ? error.message : "invalid token";
       return { valid: false, reason };
     }
   }
src/backend/tui/approval-tui.tsx (1)

72-76: Swallowed rejection from service.approve().

void service.approve(...) discards the promise. If JWT signing fails (e.g., invalid key state), the error is silently lost and the TUI gives no feedback. Consider adding a .catch() to surface failures.

🔧 Proposed fix
       const connection = pending[selectedIndex];
       if (connection) {
-        void service.approve(connection.id);
+        service.approve(connection.id).catch(() => {
+          // Approval failure is surfaced by the absence of the "approved" event
+        });
       }
src/backend/cli.ts (1)

130-134: jwtSecret is allocated unconditionally even when approval is disabled.

When --no-approve is set, approvalService is undefined and the secret is never used. Move the secret generation inside the conditional to avoid the unnecessary allocation.

♻️ Proposed fix
   const approvalEnabled = !args.noApprove;
-  const jwtSecret = crypto.randomBytes(32);
   const approvalService = approvalEnabled
-    ? new ApprovalService({ jwtSecret, jwtLifetimeSecs: args.jwtLifetime })
+    ? new ApprovalService({ jwtSecret: crypto.randomBytes(32), jwtLifetimeSecs: args.jwtLifetime })
     : undefined;
tests/integration/approval-flow.test.ts (1)

217-217: Fixed setTimeout delays are fragile synchronization

Three tests use await new Promise((resolve) => setTimeout(resolve, 50)) to wait for async side-effects (close propagation, auth processing). These can silently pass on fast machines and flake on slow CI.

  • Line 217 (after control1.close()): wait for close to propagate before reconnecting.
  • Line 283 (after control.close()): wait for removePending to run.
  • Line 302 (after terminal auth): wait for JWT verification to set ctx.authed.

For lines 217 and 283 (socket close), polling approvalService.getPending().length with a short poll loop or using a one-shot "pending-removed" event from approvalService would be deterministic. For line 302, having the server send a small acknowledgement on terminal auth success (or polling terminal.readyState) would be better.

Also applies to: 283-283, 302-302

src/backend/server.ts (2)

313-323: Control WS lacks the authInProgress guard present on terminal WS

Terminal WS sets ctx.authInProgress = true before await approvalService.verifyJwt(...) and back to false after, dropping any messages that arrive during the async window. Control WS has no equivalent. Because socket.on("message", async ...) handlers run concurrently in the Node.js event loop, a second auth message arriving while the first is still in await approvalService.verifyJwt(...) passes the !context.authed check (not yet set to true) and enters the auth block again — potentially triggering two completeAuth calls, two auth_approved messages, and two ensureAttachedSession invocations.

Add a pendingAuth flag to ControlContext (analogous to authInProgress in DataContext) and guard early:

  interface ControlContext {
    socket: WebSocket;
    authed: boolean;
    clientId: string;
    pendingApprovalId?: string;
+   authInProgress?: boolean;
  }
  if (!context.authed) {
+   if (context.authInProgress) return;
    if (message.type !== "auth") { ... }

    if (message.jwt && approvalService) {
+     context.authInProgress = true;
      const jwtResult = await approvalService.verifyJwt(message.jwt);
+     context.authInProgress = false;
      ...
    }
    ...
  }

Also applies to: 401-429


208-228: approvalService event listeners are never removed on stop()

approvalService.on("approved", ...) and approvalService.on("denied", ...) are registered once at creation time and never removed. If an ApprovalService instance outlives one server lifecycle (e.g., shared across a restart or held by a test), the old listeners — which close over the first server's controlClients set — remain active and would fire against a stale context.

Consider removing the listeners in stop():

+ let approvedListener: ((e: {...}) => void) | undefined;
+ let deniedListener: ((e: {...}) => void) | undefined;

  if (approvalService) {
-   approvalService.on("approved", (event) => { ... });
-   approvalService.on("denied", (event) => { ... });
+   approvedListener = (event) => { ... };
+   deniedListener  = (event) => { ... };
+   approvalService.on("approved", approvedListener);
+   approvalService.on("denied",  deniedListener);
  }

  // inside stop():
+ if (approvalService && approvedListener) approvalService.off("approved", approvedListener);
+ if (approvalService && deniedListener)  approvalService.off("denied",  deniedListener);
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between faa1c26 and 439d7c7.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (18)
  • package.json
  • src/backend/auth/approval-service.ts
  • src/backend/auth/approval-types.ts
  • src/backend/cli.ts
  • src/backend/config.ts
  • src/backend/server.ts
  • src/backend/tui/approval-tui.tsx
  • src/backend/types/protocol.ts
  • src/backend/util/geoip.ts
  • src/frontend/App.tsx
  • src/frontend/styles/app.css
  • src/frontend/types/protocol.ts
  • tests/backend/approval-service.test.ts
  • tests/backend/geoip.test.ts
  • tests/e2e/harness/test-server.ts
  • tests/integration/approval-flow.test.ts
  • tests/integration/server.test.ts
  • tsconfig.backend.json
🧰 Additional context used
📓 Path-based instructions (2)
**/types/protocol.ts

📄 CodeRabbit inference engine (AGENTS.md)

Keep protocol types in sync between backend (src/backend/types/protocol.ts) and frontend (src/frontend/types/protocol.ts)

Files:

  • src/frontend/types/protocol.ts
  • src/backend/types/protocol.ts
**/server.ts

📄 CodeRabbit inference engine (AGENTS.md)

Preserve independent auth gating on both /ws/control and /ws/terminal WebSocket endpoints

Files:

  • src/backend/server.ts
🧠 Learnings (3)
📚 Learning: 2026-02-14T22:15:23.148Z
Learnt from: CR
Repo: DagsHub/tmux-mobile PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-14T22:15:23.148Z
Learning: Applies to **/server.ts : Preserve independent auth gating on both `/ws/control` and `/ws/terminal` WebSocket endpoints

Applied to files:

  • src/backend/auth/approval-types.ts
  • src/frontend/types/protocol.ts
  • tests/integration/approval-flow.test.ts
  • src/backend/types/protocol.ts
  • tests/backend/approval-service.test.ts
  • src/backend/cli.ts
  • src/backend/server.ts
  • tests/integration/server.test.ts
  • src/frontend/App.tsx
📚 Learning: 2026-02-14T22:15:23.148Z
Learnt from: CR
Repo: DagsHub/tmux-mobile PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-14T22:15:23.148Z
Learning: Applies to **/types/protocol.ts : Keep protocol types in sync between backend (`src/backend/types/protocol.ts`) and frontend (`src/frontend/types/protocol.ts`)

Applied to files:

  • src/backend/auth/approval-types.ts
  • src/frontend/types/protocol.ts
  • tsconfig.backend.json
  • src/backend/types/protocol.ts
  • src/backend/server.ts
📚 Learning: 2026-02-14T22:15:23.148Z
Learnt from: CR
Repo: DagsHub/tmux-mobile PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-14T22:15:23.148Z
Learning: Applies to **/tmux/**/*.ts : Use `execFile` with argument arrays in tmux command execution to prevent shell injection attacks

Applied to files:

  • tests/integration/server.test.ts
🧬 Code graph analysis (6)
src/backend/auth/approval-service.ts (2)
src/backend/auth/approval-types.ts (2)
  • PendingConnection (3-12)
  • ApprovalServiceOptions (14-17)
src/backend/util/random.ts (1)
  • randomToken (3-4)
tests/backend/geoip.test.ts (1)
src/backend/util/geoip.ts (1)
  • resolveGeo (6-14)
tests/backend/approval-service.test.ts (2)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/auth/approval-types.ts (1)
  • PendingConnection (3-12)
src/backend/cli.ts (2)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/tui/approval-tui.tsx (1)
  • renderApprovalTui (126-128)
src/backend/server.ts (5)
src/backend/tmux/types.ts (1)
  • TmuxGateway (9-24)
src/backend/pty/pty-adapter.ts (1)
  • PtyFactory (9-11)
src/backend/auth/auth-service.ts (1)
  • AuthService (8-32)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/util/geoip.ts (1)
  • resolveGeo (6-14)
src/backend/tui/approval-tui.tsx (2)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/auth/approval-types.ts (1)
  • PendingConnection (3-12)
🪛 Stylelint (17.3.0)
src/frontend/styles/app.css

[error] 680-680: Unexpected quotes around "Menlo" (font-family-name-quotes)

(font-family-name-quotes)


[error] 680-680: Unexpected quotes around "Monaco" (font-family-name-quotes)

(font-family-name-quotes)

🔇 Additional comments (14)
tests/e2e/harness/test-server.ts (1)

44-48: LGTM — correctly satisfies the new required RuntimeConfig fields.

src/backend/util/geoip.ts (1)

1-14: LGTM — clean implementation with helpful privacy comment.

src/backend/auth/approval-types.ts (1)

1-17: LGTM — well-typed interfaces; import type for WebSocket avoids unnecessary runtime import.

tsconfig.backend.json (1)

10-10: No action needed — the base tsconfig.json already has "jsx": "react-jsx" set, so TypeScript will successfully compile the .tsx files included in tsconfig.backend.json without error. The configuration is correct as-is.

Likely an incorrect or invalid review comment.

src/backend/types/protocol.ts (1)

50-52: The frontend protocol file properly mirrors the backend auth variants.

The three new ControlServerMessage variants (auth_pending, auth_approved, auth_denied) are correctly defined in both src/backend/types/protocol.ts and src/frontend/types/protocol.ts with matching field signatures.

src/backend/tui/approval-tui.tsx (1)

30-85: LGTM — well-structured TUI component.

Event subscriptions are properly cleaned up, the selected-index clamping is correct, and the keyboard input handling covers the expected interactions cleanly.

tests/integration/server.test.ts (1)

21-23: LGTM — test config updated to match the expanded RuntimeConfig interface.

tests/backend/approval-service.test.ts (1)

1-143: LGTM — solid test coverage for ApprovalService.

Good coverage of the core lifecycle (add/approve/deny/remove), event emissions, JWT signing/verification, and cross-secret rejection. The tests are clean and well-organized.

src/backend/cli.ts (1)

186-189: Good use of dynamic import for the TUI module.

This avoids loading Ink and React when approval is disabled — nice optimization for the common --no-approve path.

src/frontend/App.tsx (4)

59-64: JWT stored in localStorage — comment adequately documents the trade-off.

The security comment is appreciated. The single-origin, no-third-party-scripts assumption is reasonable for this use case. If third-party scripts are ever introduced, this should be revisited.


867-886: Approval overlay UI looks good.

Clean implementation with challenge code display, spinner, and a fallback to password entry. The data-testid attributes enable integration testing.


236-248: Auth payload construction — jwt and password are mutually exclusive.

The if/else if logic means that if both jwtValue and passwordValue are provided, only the JWT is sent. This is the correct precedence, but worth noting for reviewers that a JWT always takes priority over a password in the auth payload.


515-521: switchToPasswordEntry closes the control socket — user must re-authenticate.

Calling controlSocketRef.current?.close() means the user loses the WebSocket connection. When they later submit a password via submitPassword(), openControlSocket(password) is called (line 512), which creates a new socket. This flow is correct and clean.

src/frontend/types/protocol.ts (1)

36-38: The protocol types between backend (src/backend/types/protocol.ts) and frontend (src/frontend/types/protocol.ts) are already in sync. The three new ControlServerMessage variants—auth_pending, auth_approved, and auth_denied—match identically across both files with the correct shapes and types.

Comment on lines +35 to +51
public async approve(id: string): Promise<{ connectionId: string; jwt: string } | null> {
const connection = this.pending.get(id);
if (!connection) {
return null;
}

const jwt = await new SignJWT({ clientId: connection.clientId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${this.jwtLifetimeSecs}s`)
.sign(this.jwtSecret);

this.pending.delete(id);
const result = { connectionId: id, jwt };
this.emit("approved", { ...result, clientId: connection.clientId });
return result;
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Duplicated JWT signing logic between approve() and signJwt().

Lines 41–45 replicate the same SignJWT chain found in signJwt() (lines 73–78). approve() should delegate to signJwt() to keep the signing configuration in one place.

♻️ Proposed refactor
   public async approve(id: string): Promise<{ connectionId: string; jwt: string } | null> {
     const connection = this.pending.get(id);
     if (!connection) {
       return null;
     }
 
-    const jwt = await new SignJWT({ clientId: connection.clientId })
-      .setProtectedHeader({ alg: "HS256" })
-      .setIssuedAt()
-      .setExpirationTime(`${this.jwtLifetimeSecs}s`)
-      .sign(this.jwtSecret);
+    const jwt = await this.signJwt(connection.clientId);
 
     this.pending.delete(id);
     const result = { connectionId: id, jwt };
     this.emit("approved", { ...result, clientId: connection.clientId });
     return result;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async approve(id: string): Promise<{ connectionId: string; jwt: string } | null> {
const connection = this.pending.get(id);
if (!connection) {
return null;
}
const jwt = await new SignJWT({ clientId: connection.clientId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${this.jwtLifetimeSecs}s`)
.sign(this.jwtSecret);
this.pending.delete(id);
const result = { connectionId: id, jwt };
this.emit("approved", { ...result, clientId: connection.clientId });
return result;
}
public async approve(id: string): Promise<{ connectionId: string; jwt: string } | null> {
const connection = this.pending.get(id);
if (!connection) {
return null;
}
const jwt = await this.signJwt(connection.clientId);
this.pending.delete(id);
const result = { connectionId: id, jwt };
this.emit("approved", { ...result, clientId: connection.clientId });
return result;
}

Comment on lines +195 to +204
try {
await ensureAttachedSession(context.socket);
} catch (error) {
logger.error("initial attach failed", error);
sendJson(context.socket, {
type: "error",
message: error instanceof Error ? error.message : String(error)
});
}
await monitor?.forcePublish();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

monitor?.forcePublish() is outside the try/catch in completeAuth

The try/catch at lines 195–203 covers only ensureAttachedSession. If monitor.forcePublish() rejects, the exception propagates to the caller. When completeAuth is invoked via void completeAuth(...) (line 213), that rejection is silently dropped. Extend the guard or wrap the forcePublish call:

-   } catch (error) {
+   } catch (error) {  // covers ensureAttachedSession
      logger.error("initial attach failed", error);
      sendJson(context.socket, { ... });
    }
-   await monitor?.forcePublish();
+   try {
+     await monitor?.forcePublish();
+   } catch (error) {
+     logger.error("forcePublish failed", error);
+   }
  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await ensureAttachedSession(context.socket);
} catch (error) {
logger.error("initial attach failed", error);
sendJson(context.socket, {
type: "error",
message: error instanceof Error ? error.message : String(error)
});
}
await monitor?.forcePublish();
try {
await ensureAttachedSession(context.socket);
} catch (error) { // covers ensureAttachedSession
logger.error("initial attach failed", error);
sendJson(context.socket, {
type: "error",
message: error instanceof Error ? error.message : String(error)
});
}
try {
await monitor?.forcePublish();
} catch (error) {
logger.error("forcePublish failed", error);
}

Comment on lines +313 to +323
if (message.jwt && approvalService) {
const jwtResult = await approvalService.verifyJwt(message.jwt);
if (jwtResult.valid) {
logger.log("control ws jwt auth ok", context.clientId);
const freshJwt = await approvalService.signJwt(context.clientId);
await completeAuth(context, freshJwt);
return;
}
logger.log("control ws jwt invalid", context.clientId, jwtResult.reason);
// JWT invalid — fall through to password check
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

JWT auth path skips message.token validation — access token can be bypassed

When a JWT is present and valid, completeAuth is called immediately without verifying message.token against authService.token. A caller who possesses a valid JWT but does not know the server's access token (e.g., JWT leaked from localStorage while the URL token is not known) can authenticate on the control WebSocket. The same gap exists on the terminal WebSocket (lines 420–429).

The server's access token is the first factor; the JWT is a derived credential issued only after the token was verified. The JWT path should still enforce the first factor:

🔒 Proposed fix — check token before accepting JWT (control WS)
  // 1. JWT authentication (if approvalService available and JWT provided)
  if (message.jwt && approvalService) {
+   const tokenCheck = authService.verify({ token: message.token });
+   if (!tokenCheck.ok) {
+     sendJson(socket, { type: "auth_error", reason: tokenCheck.reason ?? "invalid token" });
+     return;
+   }
    const jwtResult = await approvalService.verifyJwt(message.jwt);
    if (jwtResult.valid) {

Apply the same guard to the terminal WS JWT path at lines 420–429.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (message.jwt && approvalService) {
const jwtResult = await approvalService.verifyJwt(message.jwt);
if (jwtResult.valid) {
logger.log("control ws jwt auth ok", context.clientId);
const freshJwt = await approvalService.signJwt(context.clientId);
await completeAuth(context, freshJwt);
return;
}
logger.log("control ws jwt invalid", context.clientId, jwtResult.reason);
// JWT invalid — fall through to password check
}
if (message.jwt && approvalService) {
const tokenCheck = authService.verify({ token: message.token });
if (!tokenCheck.ok) {
sendJson(socket, { type: "auth_error", reason: tokenCheck.reason ?? "invalid token" });
return;
}
const jwtResult = await approvalService.verifyJwt(message.jwt);
if (jwtResult.valid) {
logger.log("control ws jwt auth ok", context.clientId);
const freshJwt = await approvalService.signJwt(context.clientId);
await completeAuth(context, freshJwt);
return;
}
logger.log("control ws jwt invalid", context.clientId, jwtResult.reason);
// JWT invalid — fall through to password check
}

Comment on lines +679 to +686
.challenge-code {
font-family: "Menlo", "Monaco", "Courier New", monospace;
font-size: 2.4rem;
font-weight: 700;
letter-spacing: 0.35em;
color: var(--accent);
padding: 0.6rem 0;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove unnecessary quotes around single-word font names.

Stylelint flags "Menlo" and "Monaco" — single-word font family names don't require quotes. Multi-word names like "Courier New" are fine as-is.

🔧 Proposed fix
 .challenge-code {
-  font-family: "Menlo", "Monaco", "Courier New", monospace;
+  font-family: Menlo, Monaco, "Courier New", monospace;
   font-size: 2.4rem;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.challenge-code {
font-family: "Menlo", "Monaco", "Courier New", monospace;
font-size: 2.4rem;
font-weight: 700;
letter-spacing: 0.35em;
color: var(--accent);
padding: 0.6rem 0;
}
.challenge-code {
font-family: Menlo, Monaco, "Courier New", monospace;
font-size: 2.4rem;
font-weight: 700;
letter-spacing: 0.35em;
color: var(--accent);
padding: 0.6rem 0;
}
🧰 Tools
🪛 Stylelint (17.3.0)

[error] 680-680: Unexpected quotes around "Menlo" (font-family-name-quotes)

(font-family-name-quotes)


[error] 680-680: Unexpected quotes around "Monaco" (font-family-name-quotes)

(font-family-name-quotes)

Comment on lines +16 to +22
test("returns location string for known public IP", () => {
// 8.8.8.8 is Google DNS — geoip-lite has data for it
const result = resolveGeo("8.8.8.8");
// Should be a non-empty string (exact result depends on geoip-lite DB version)
expect(result).not.toBe("Unknown");
expect(result.length).toBeGreaterThan(0);
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

The 8.8.8.8 assertion is environment-dependent and could become a flaky test.

The test asserts a non-"Unknown" result for 8.8.8.8 relying on the geoip-lite bundled MaxMind snapshot always containing data for Google's DNS server. While currently reliable, the assertion would silently fail if the database is stale, stripped, or if geoip-lite changes how it ships its DB. Consider adding a vi.mock or a describe.skipIf guard for CI environments where geolocation data might not be guaranteed, or restructuring to test the formatting logic independently from the live lookup.

Comment on lines +60 to +74
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const idx = waiters.findIndex((w) => w.resolve === resolve);
if (idx >= 0) waiters.splice(idx, 1);
reject(new Error(`Timed out waiting for message. Received: ${JSON.stringify(messages.map((m) => m.type))}`));
}, timeoutMs);

waiters.push({
matcher,
resolve: (msg) => {
clearTimeout(timeout);
resolve(msg);
},
reject
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

waiters stale entry on timeout — waiter is never cleaned up

The timeout callback searches:

const idx = waiters.findIndex((w) => w.resolve === resolve);

But the pushed entry stores the wrapper function — (msg) => { clearTimeout(timeout); resolve(msg); } — not the original resolve from new Promise(...). The identity comparison always returns -1, so the waiter is never spliced out when the timeout fires. The entry stays in the array until the next matching message arrives and removes it as part of the normal notification loop.

Practical risk: if a test times out and a later message in the same socket session matches the stale predicate, the wrapper is called on the already-rejected promise (no-op), but the stale entry still consumes an array slot and affects iteration count.

🛠 Proposed fix — capture wrapper reference for cleanup
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
-     const idx = waiters.findIndex((w) => w.resolve === resolve);
+     const idx = waiters.findIndex((w) => w.resolve === wrappedResolve);
      if (idx >= 0) waiters.splice(idx, 1);
      reject(new Error(`Timed out waiting for message. Received: ${JSON.stringify(messages.map((m) => m.type))}`));
    }, timeoutMs);

+   const wrappedResolve = (msg: Record<string, unknown>): void => {
+     clearTimeout(timeout);
+     resolve(msg);
+   };
    waiters.push({
      matcher,
-     resolve: (msg) => {
-       clearTimeout(timeout);
-       resolve(msg);
-     },
+     resolve: wrappedResolve,
      reject
    });
  });

Comment on lines +326 to +354
test("multiple pending connections are tracked independently", async () => {
const control1 = await openSocket(`${baseWsUrl}/ws/control`);
const control2 = await openSocket(`${baseWsUrl}/ws/control`);
const { waitFor: waitFor1 } = collectMessages(control1);
const { waitFor: waitFor2 } = collectMessages(control2);

control1.send(JSON.stringify({ type: "auth", token: "test-token" }));
control2.send(JSON.stringify({ type: "auth", token: "test-token" }));

const pending1 = await waitFor1((msg) => msg.type === "auth_pending") as { type: string; challengeCode: string };
const pending2 = await waitFor2((msg) => msg.type === "auth_pending") as { type: string; challengeCode: string };

expect(approvalService.getPending()).toHaveLength(2);
expect(pending1.challengeCode).toBeTruthy();
expect(pending2.challengeCode).toBeTruthy();

// Approve only the first
const pendingList = approvalService.getPending();
await approvalService.approve(pendingList[0].id);

const approved = await waitFor1((msg) => msg.type === "auth_approved") as { type: string };
expect(approved.type).toBe("auth_approved");

// Second should still be pending
expect(approvalService.getPending()).toHaveLength(1);

control1.close();
control2.close();
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Ordering assumption on pendingList[0] may cause intermittent failures

Lines 343–346 assume pendingList[0] belongs to control1, then wait for auth_approved on control1 via waitFor1. The Map preserves insertion order, but the server processes control1.send and control2.send based on which message arrives first in the event loop — not send-call order. If control2's message is processed first, pendingList[0] is control2's entry; approving it sends auth_approved to control2, not control1, and waitFor1 times out.

Use the challengeCode from the already-awaited pending1 to find the correct pending entry:

  const pendingList = approvalService.getPending();
- await approvalService.approve(pendingList[0].id);
+ const entry1 = pendingList.find((p) => p.challengeCode === pending1.challengeCode);
+ expect(entry1).toBeDefined();
+ await approvalService.approve(entry1!.id);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("multiple pending connections are tracked independently", async () => {
const control1 = await openSocket(`${baseWsUrl}/ws/control`);
const control2 = await openSocket(`${baseWsUrl}/ws/control`);
const { waitFor: waitFor1 } = collectMessages(control1);
const { waitFor: waitFor2 } = collectMessages(control2);
control1.send(JSON.stringify({ type: "auth", token: "test-token" }));
control2.send(JSON.stringify({ type: "auth", token: "test-token" }));
const pending1 = await waitFor1((msg) => msg.type === "auth_pending") as { type: string; challengeCode: string };
const pending2 = await waitFor2((msg) => msg.type === "auth_pending") as { type: string; challengeCode: string };
expect(approvalService.getPending()).toHaveLength(2);
expect(pending1.challengeCode).toBeTruthy();
expect(pending2.challengeCode).toBeTruthy();
// Approve only the first
const pendingList = approvalService.getPending();
await approvalService.approve(pendingList[0].id);
const approved = await waitFor1((msg) => msg.type === "auth_approved") as { type: string };
expect(approved.type).toBe("auth_approved");
// Second should still be pending
expect(approvalService.getPending()).toHaveLength(1);
control1.close();
control2.close();
});
test("multiple pending connections are tracked independently", async () => {
const control1 = await openSocket(`${baseWsUrl}/ws/control`);
const control2 = await openSocket(`${baseWsUrl}/ws/control`);
const { waitFor: waitFor1 } = collectMessages(control1);
const { waitFor: waitFor2 } = collectMessages(control2);
control1.send(JSON.stringify({ type: "auth", token: "test-token" }));
control2.send(JSON.stringify({ type: "auth", token: "test-token" }));
const pending1 = await waitFor1((msg) => msg.type === "auth_pending") as { type: string; challengeCode: string };
const pending2 = await waitFor2((msg) => msg.type === "auth_pending") as { type: string; challengeCode: string };
expect(approvalService.getPending()).toHaveLength(2);
expect(pending1.challengeCode).toBeTruthy();
expect(pending2.challengeCode).toBeTruthy();
// Approve only the first
const pendingList = approvalService.getPending();
const entry1 = pendingList.find((p) => p.challengeCode === pending1.challengeCode);
expect(entry1).toBeDefined();
await approvalService.approve(entry1!.id);
const approved = await waitFor1((msg) => msg.type === "auth_approved") as { type: string };
expect(approved.type).toBe("auth_approved");
// Second should still be pending
expect(approvalService.getPending()).toHaveLength(1);
control1.close();
control2.close();
});

guysmoilov and others added 5 commits February 18, 2026 16:03
)

- Create Ink-based server-side TUI for approving/denying connections
- Add frontend waiting overlay with challenge code display
- Replace cleartext password storage with JWT in localStorage
- Handle auth_pending, auth_approved, auth_denied message types
- Add "Enter Password Instead" fallback from approval overlay
- Add CSS for approval card, challenge code, and spinner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Unit tests for ApprovalService (14 tests): pending management, JWT signing/verification, events
- Unit tests for geoip resolver (3 tests): private IPs, invalid input, public IP lookup
- Integration tests for approval flow (15 tests): password auth with JWT issuance, pending approval
  state, approve/deny flow, JWT reconnection, invalid JWT fallback, socket cleanup, terminal WS
  JWT auth, multiple pending connections, /api/config endpoint, --no-approve behavior
- Fix existing test configs to include new approvalEnabled/jwtLifetimeSecs fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@guysmoilov guysmoilov force-pushed the feat/issue-21-connection-approval-tui branch from 439d7c7 to 533141c Compare February 18, 2026 14:06
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (9)
src/frontend/styles/app.css (1)

702-716: Add a prefers-reduced-motion guard for the spinner animation.

The spin animation runs unconditionally, which is problematic for users who have configured their OS to reduce motion.

♿ Proposed fix for reduced-motion accessibility
+@media (prefers-reduced-motion: reduce) {
+  .approval-spinner {
+    animation: none;
+    border-top-color: var(--accent);
+    opacity: 0.6;
+  }
+}
src/backend/tui/approval-tui.tsx (1)

67-85: Minor: down-arrow on an empty list sets selectedIndex to -1.

When pending is empty, pressing down-arrow computes Math.min(-1, i + 1), resulting in selectedIndex = -1. The clamp effect on line 62 won't correct it because -1 >= 0 is false. This has no visible impact since the empty-state branch renders, but it leaves state inconsistent. Consider guarding like you already do for a/d:

Proposed fix
     if (key.upArrow) {
       setSelectedIndex((i) => Math.max(0, i - 1));
     } else if (key.downArrow) {
-      setSelectedIndex((i) => Math.min(pending.length - 1, i + 1));
+      if (pending.length > 0) {
+        setSelectedIndex((i) => Math.min(pending.length - 1, i + 1));
+      }
     } else if (input === "a" && pending.length > 0) {
src/backend/auth/approval-service.ts (1)

17-29: Challenge code generation is subtly coupled to base64url output length.

randomToken(3) produces exactly 4 base64url characters for 3 bytes, so .slice(0, 4) is always a no-op. If someone changes the byte size, the challenge code length changes unpredictably. Consider making the intent explicit:

Proposed clarification
-    const challengeCode = randomToken(3).slice(0, 4).toUpperCase();
+    // 3 bytes → 4 base64url chars; slice ensures exactly 4 chars
+    const challengeCode = randomToken(3).slice(0, 4).toUpperCase();

Or decouple entirely:

-    const challengeCode = randomToken(3).slice(0, 4).toUpperCase();
+    const challengeCode = crypto.randomBytes(2).toString("hex").toUpperCase(); // 4 hex chars
src/backend/cli.ts (1)

129-133: jwtSecret is generated even when approval is disabled.

crypto.randomBytes(32) on line 130 runs unconditionally. When approvalEnabled is false, the secret is never used. This is trivial overhead, but moving it inside the conditional would make the intent clearer.

Proposed change
   const approvalEnabled = !args.noApprove;
-  const jwtSecret = crypto.randomBytes(32);
   const approvalService = approvalEnabled
-    ? new ApprovalService({ jwtSecret, jwtLifetimeSecs: args.jwtLifetime })
+    ? new ApprovalService({ jwtSecret: crypto.randomBytes(32), jwtLifetimeSecs: args.jwtLifetime })
     : undefined;
src/backend/server.ts (1)

249-270: Approval event listeners are never removed on server stop.

The approvalService.on("approved", ...) and on("denied", ...) listeners registered here are never cleaned up in the stop() method. If the server is stopped and a new one created sharing the same approvalService instance (e.g. in tests), the stale listeners will fire against a dead controlClients set.

♻️ Proposed fix — remove listeners on stop

Store the handler references and remove them in stop():

+  let approvedHandler: ((event: { connectionId: string; jwt: string; clientId: string }) => void) | undefined;
+  let deniedHandler: ((event: { connectionId: string }) => void) | undefined;
+
   if (approvalService) {
-    approvalService.on("approved", (event: { connectionId: string; jwt: string; clientId: string }) => {
+    approvedHandler = (event: { connectionId: string; jwt: string; clientId: string }) => {
       // ...existing logic...
-    });
-    approvalService.on("denied", (event: { connectionId: string }) => {
+    };
+    deniedHandler = (event: { connectionId: string }) => {
       // ...existing logic...
-    });
+    };
+    approvalService.on("approved", approvedHandler);
+    approvalService.on("denied", deniedHandler);
   }

Then in stop():

     stopPromise = (async () => {
       logger.log("server shutdown begin");
       monitor?.stop();
+      if (approvalService && approvedHandler) approvalService.off("approved", approvedHandler);
+      if (approvalService && deniedHandler) approvalService.off("denied", deniedHandler);
src/frontend/App.tsx (4)

294-305: auth_ok path still persists cleartext password in localStorage.

The PR objective states "Replace cleartext password storage with JWT stored in localStorage," but when the server responds with auth_ok (non-approval path, line 294), the cleartext password is still saved at line 301. This means users on servers without approval enabled continue to have their password stored in the clear.

If the intent is to phase out cleartext storage entirely, this path should also use a JWT (when approvalService is present on the server, the server already issues one in the auth_ok flow). If this is intentional for backward compatibility, consider adding a comment.


236-247: Terminal socket auth payload sends either JWT or password, never both — silent auth failure if JWT is present but invalid.

When jwtValue is truthy (line 242), the password is omitted from the auth payload. If the JWT turns out to be invalid/expired on the server, the terminal WS currently closes with 4001 (line 513 on server). The client then shows "terminal authentication failed" but has no automatic fallback to re-try with the password.

This is acceptable if the control socket has already validated the JWT (and refreshed it), but worth noting that a race between JWT expiry and terminal socket open could cause a transient failure with no retry.


59-64: JWT stored in localStorage with no expiry-awareness on the client side.

The JWT is stored and re-used on reconnect, but there's no client-side check of the JWT's expiry (exp claim) before sending it. This means the client will always attempt a round-trip with a potentially expired JWT before discovering it's invalid.

This is a minor inefficiency — not a correctness issue — since the server validates it. But parsing the exp claim and skipping the JWT if it's expired would save a wasted connection attempt.


867-886: Approval overlay looks good — minor accessibility note.

The overlay correctly renders the challenge code as text content (safe from XSS in React) and provides a password fallback button. The data-testid attributes are present for testing.

One small accessibility improvement: consider adding aria-live="polite" to the overlay or the challenge code element so screen readers announce the approval state when it appears.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 84fe10e and 533141c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (18)
  • package.json
  • src/backend/auth/approval-service.ts
  • src/backend/auth/approval-types.ts
  • src/backend/cli.ts
  • src/backend/config.ts
  • src/backend/server.ts
  • src/backend/tui/approval-tui.tsx
  • src/backend/types/protocol.ts
  • src/backend/util/geoip.ts
  • src/frontend/App.tsx
  • src/frontend/styles/app.css
  • src/frontend/types/protocol.ts
  • tests/backend/approval-service.test.ts
  • tests/backend/geoip.test.ts
  • tests/e2e/harness/test-server.ts
  • tests/integration/approval-flow.test.ts
  • tests/integration/server.test.ts
  • tsconfig.backend.json
🧰 Additional context used
📓 Path-based instructions (2)
**/types/protocol.ts

📄 CodeRabbit inference engine (AGENTS.md)

Keep protocol types in sync between backend (src/backend/types/protocol.ts) and frontend (src/frontend/types/protocol.ts)

Files:

  • src/frontend/types/protocol.ts
  • src/backend/types/protocol.ts
**/server.ts

📄 CodeRabbit inference engine (AGENTS.md)

Preserve independent auth gating on both /ws/control and /ws/terminal WebSocket endpoints

Files:

  • src/backend/server.ts
🧠 Learnings (3)
📚 Learning: 2026-02-14T22:15:23.148Z
Learnt from: CR
Repo: DagsHub/tmux-mobile PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-14T22:15:23.148Z
Learning: Applies to **/types/protocol.ts : Keep protocol types in sync between backend (`src/backend/types/protocol.ts`) and frontend (`src/frontend/types/protocol.ts`)

Applied to files:

  • src/backend/auth/approval-types.ts
  • src/frontend/types/protocol.ts
  • src/backend/types/protocol.ts
  • tsconfig.backend.json
  • src/backend/server.ts
📚 Learning: 2026-02-14T22:15:23.148Z
Learnt from: CR
Repo: DagsHub/tmux-mobile PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-14T22:15:23.148Z
Learning: Applies to **/server.ts : Preserve independent auth gating on both `/ws/control` and `/ws/terminal` WebSocket endpoints

Applied to files:

  • src/backend/auth/approval-types.ts
  • tests/integration/approval-flow.test.ts
  • tests/backend/approval-service.test.ts
  • src/frontend/types/protocol.ts
  • src/backend/types/protocol.ts
  • tests/integration/server.test.ts
  • src/backend/server.ts
  • src/frontend/App.tsx
📚 Learning: 2026-02-14T22:15:23.148Z
Learnt from: CR
Repo: DagsHub/tmux-mobile PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-14T22:15:23.148Z
Learning: Applies to **/tmux/**/*.ts : Use `execFile` with argument arrays in tmux command execution to prevent shell injection attacks

Applied to files:

  • tests/integration/server.test.ts
🧬 Code graph analysis (6)
tests/integration/approval-flow.test.ts (4)
src/backend/server.ts (2)
  • RunningServer (48-53)
  • createTmuxMobileServer (86-648)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/auth/auth-service.ts (1)
  • AuthService (8-32)
tests/harness/ws.ts (1)
  • openSocket (34-38)
src/backend/tui/approval-tui.tsx (2)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/auth/approval-types.ts (1)
  • PendingConnection (3-12)
tests/backend/approval-service.test.ts (2)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/auth/approval-types.ts (1)
  • PendingConnection (3-12)
tests/backend/geoip.test.ts (1)
src/backend/util/geoip.ts (1)
  • resolveGeo (6-14)
src/backend/auth/approval-service.ts (2)
src/backend/auth/approval-types.ts (2)
  • PendingConnection (3-12)
  • ApprovalServiceOptions (14-17)
src/backend/util/random.ts (1)
  • randomToken (3-4)
src/backend/cli.ts (2)
src/backend/auth/approval-service.ts (1)
  • ApprovalService (6-88)
src/backend/tui/approval-tui.tsx (1)
  • renderApprovalTui (126-128)
🪛 GitHub Check: test-and-build
tests/integration/approval-flow.test.ts

[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > terminal WS authenticates with JWT
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3


[failure] 305-305: tests/integration/approval-flow.test.ts > approval flow integration > terminal WS authenticates with JWT
AssertionError: expected 3 to be 1 // Object.is equality

  • Expected
  • Received
  • 1
  • 3

❯ tests/integration/approval-flow.test.ts:305:33


[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > JWT reconnection skips password and approval
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3


[failure] 232-232: tests/integration/approval-flow.test.ts > approval flow integration > JWT reconnection skips password and approval
AssertionError: expected 'tmux-mobile-client-zfgwUT7YFxBOhrYL' to be 'main' // Object.is equality

Expected: "main"
Received: "tmux-mobile-client-zfgwUT7YFxBOhrYL"

❯ tests/integration/approval-flow.test.ts:232:30


[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > approving a pending connection sends auth_approved with JWT
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3


[failure] 185-185: tests/integration/approval-flow.test.ts > approval flow integration > approving a pending connection sends auth_approved with JWT
AssertionError: expected 'tmux-mobile-client-ETVBJEOvFIG1Tw86' to be 'main' // Object.is equality

Expected: "main"
Received: "tmux-mobile-client-ETVBJEOvFIG1Tw86"

❯ tests/integration/approval-flow.test.ts:185:30


[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > password auth succeeds and issues JWT via auth_approved
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3


[failure] 142-142: tests/integration/approval-flow.test.ts > approval flow integration > password auth succeeds and issues JWT via auth_approved
AssertionError: expected 'tmux-mobile-client-SrOJwKInnQGS-1y1' to be 'main' // Object.is equality

Expected: "main"
Received: "tmux-mobile-client-SrOJwKInnQGS-1y1"

❯ tests/integration/approval-flow.test.ts:142:30

🪛 Stylelint (17.3.0)
src/frontend/styles/app.css

[error] 688-688: Unexpected quotes around "Menlo" (font-family-name-quotes)

(font-family-name-quotes)


[error] 688-688: Unexpected quotes around "Monaco" (font-family-name-quotes)

(font-family-name-quotes)

🔇 Additional comments (16)
src/backend/util/geoip.ts (1)

1-14: LGTM — clean and correct implementation.

The null guard, filter(Boolean) for missing city/region/country fields, and the dual "Unknown" fallback all handle the edge cases correctly. The privacy notice is a helpful reminder for operators.

src/backend/config.ts (1)

11-24: LGTM — clean additions to both interfaces.

The naming distinction between jwtLifetime (CliArgs) and jwtLifetimeSecs (RuntimeConfig) is idiomatic for CLI-to-runtime mapping. Making both new RuntimeConfig fields required (non-optional) ensures all construction sites are updated.

src/backend/types/protocol.ts (1)

2-2: LGTM — jwt? on the auth message enables JWT-based reconnection cleanly.

tests/integration/server.test.ts (1)

21-24: LGTM — config correctly extended with the new required fields.

approvalEnabled: false ensures existing integration tests are unaffected by the approval flow, and jwtLifetimeSecs: 86400 is a sensible default for test scenarios.

package.json (1)

55-61: React downgraded from v19 to v18 for Ink 5.x compatibility.

Ink 5.x does not work with React 19; the downgrade to React 18 is necessary. The frontend has no React 19-specific APIs in use, so the downgrade is safe.

src/frontend/types/protocol.ts (1)

36-38: New auth flow message variants look correct.

The three new union members align with the approval flow: auth_pending carries a challengeCode, auth_approved returns a jwt + clientId, and auth_denied provides a reason. These are consistent with what the tests and server-side code expect.

Per the coding guideline, verify these types are mirrored exactly in the backend protocol file.

[approve_code_changes, request_verification]

#!/bin/bash
# Verify that the backend protocol types include the same auth_pending, auth_approved, auth_denied variants
echo "=== Backend protocol.ts ==="
fd 'protocol.ts' src/backend/types --exec cat {}
echo ""
echo "=== Comparing auth variants ==="
rg -n 'auth_pending|auth_approved|auth_denied' src/backend/types/protocol.ts src/frontend/types/protocol.ts

As per coding guidelines: **/types/protocol.ts: "Keep protocol types in sync between backend (src/backend/types/protocol.ts) and frontend (src/frontend/types/protocol.ts)"

src/backend/tui/approval-tui.tsx (1)

126-128: LGTM — clean entry point.

The renderApprovalTui export cleanly mounts the Ink app with the provided service instance. Straightforward.

src/backend/auth/approval-types.ts (1)

1-17: LGTM — clean type definitions.

The PendingConnection and ApprovalServiceOptions interfaces are well-structured and properly consumed by the service, server, and tests.

tests/e2e/harness/test-server.ts (1)

45-47: LGTM — sensible defaults for E2E harness.

Approval is disabled by default in the E2E test server, consistent with pre-existing tests not needing the approval flow.

tests/backend/approval-service.test.ts (1)

1-143: Solid unit test suite.

Comprehensive coverage of the ApprovalService public API: add/approve/deny/remove flows, event emissions, JWT signing/verification, and cross-secret rejection. Well-organized with clear assertions.

src/backend/cli.ts (1)

185-188: LGTM — lazy import of TUI.

Dynamic import() of the Ink-based TUI avoids pulling in React/Ink when approval is disabled. Clean approach.

tests/integration/approval-flow.test.ts (2)

140-142: attached.session assertion fails in CI — server returns clientId, not session name.

Static analysis shows multiple tests failing here with values like "tmux-mobile-client-SrOJwKInnQGS-1y1" instead of "main". The same failure occurs at lines 185 and 232. The server's attached message appears to be sending clientId in the session field rather than the actual tmux session name. Investigate the server-side attach flow after approval to ensure it sends the correct session name.

[raise_critical_issue, request_verification]

#!/bin/bash
# Find the server-side code that sends the "attached" message to understand what value is placed in the session field
rg -n '"attached"' src/backend/server.ts -C 5
echo "---"
# Also check how the session is determined after auth_approved
rg -n 'attached.*session' src/backend/server.ts -C 3

288-309: Terminal WS JWT auth test fails — socket closes instead of staying open.

CI reports readyState is 3 (CLOSED) instead of 1 (OPEN) at line 305. The 50ms sleep on line 302 is likely insufficient for the async auth to complete, but more importantly the terminal WS auth path may not properly handle JWT-only auth (no password). Verify the server-side terminal WS auth handles the JWT path and doesn't close the socket.

Additionally, the test lacks a proper assertion mechanism — checking readyState after a fixed delay is inherently racy. Consider listening for a specific auth success/error message or using a close event with a timeout.

[raise_critical_issue, request_verification]

#!/bin/bash
# Inspect the terminal WS authentication handling in server.ts
rg -n 'terminal' src/backend/server.ts | head -30
echo "---"
# Look at how JWT auth is handled on the terminal WS path
rg -nP 'jwt|terminal.*auth' src/backend/server.ts -C 5
src/backend/server.ts (2)

97-103: /api/config exposes approvalEnabled — verify this doesn't leak sensitive posture info.

Exposing whether approval mode is active tells unauthenticated clients how auth works. This is likely intentional (the frontend needs it to decide the UI flow), but worth confirming that revealing this is acceptable from a security standpoint.


414-431: No changes needed. The narrow condition at line 414 (authResult.reason === "invalid password") is intentional design, not a bug.

The AuthService distinguishes between two failure modes: "invalid token" (client lacks proper credentials entirely) and "invalid password" (client has valid token but wrong password). Approval flow only triggers for the latter because it represents a known client requesting access with partial credentials—the intended approval scenario. Invalid tokens are rejected outright. This design is documented in code comments (line 34) and validated by tests. Both /ws/control and /ws/terminal maintain independent auth gating as required.

Likely an incorrect or invalid review comment.

src/frontend/App.tsx (1)

515-521: switchToPasswordEntry correctly closes the pending socket — server cleans up the pending approval.

Closing the control socket here triggers the server's socket.on("close") handler (server.ts line 457), which removes the pending approval. The user can then submit a password via submitPassword, which opens a fresh control socket. This flow is clean.

Comment on lines +496 to +505
if (authMessage.jwt && approvalService) {
ctx.authInProgress = true;
const jwtResult = await approvalService.verifyJwt(authMessage.jwt);
ctx.authInProgress = false;
if (jwtResult.valid) {
ctx.authed = true;
logger.log("terminal ws jwt auth ok");
return;
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Terminal WS JWT-auth path never sets controlClientId / controlContext — terminal I/O is silently broken.

When the JWT path succeeds, only ctx.authed is set to true (line 501) and the handler returns (line 503). ctx.controlClientId and ctx.controlContext are never assigned, and the terminal socket is never added to controlContext.terminalClients.

Consequences:

  • Incoming terminal data hits ctx.controlContext?.runtime?.write(...) (line 531) which is undefined?.runtime?.write(...) → silently dropped.
  • Resize messages are similarly lost (line 545).
  • The control context never knows about this terminal client, so broadcastState terminal output is never forwarded.

The non-JWT path (lines 507-526) correctly looks up controlContext and wires everything. The JWT path must do the same.

🐛 Proposed fix — wire controlContext after JWT auth
         if (authMessage.jwt && approvalService) {
           ctx.authInProgress = true;
           const jwtResult = await approvalService.verifyJwt(authMessage.jwt);
           ctx.authInProgress = false;
           if (jwtResult.valid) {
-            ctx.authed = true;
-            logger.log("terminal ws jwt auth ok");
-            return;
+            const controlContext = getControlContext(clientId);
+            if (!controlContext || !controlContext.authed) {
+              socket.close(4001, "unauthorized");
+              return;
+            }
+            ctx.authed = true;
+            ctx.controlClientId = clientId;
+            ctx.controlContext = controlContext;
+            controlContext.terminalClients.add(ctx);
+            logger.log("terminal ws jwt auth ok");
+            return;
           }
         }

Comment on lines +50 to +52
| { type: "auth_pending"; challengeCode: string }
| { type: "auth_approved"; jwt: string; clientId: string }
| { type: "auth_denied"; reason: string }
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check if the frontend protocol file exists and view its contents
if [ -f src/frontend/types/protocol.ts ]; then
    echo "=== Frontend Protocol File ===" && cat src/frontend/types/protocol.ts
else
    echo "File not found at src/frontend/types/protocol.ts"
    # Try to find the file
    find . -name "protocol.ts" -type f 2>/dev/null | head -20
fi

Repository: DagsHub/tmux-mobile

Length of output: 1281


🏁 Script executed:

# Also verify the backend file to understand the complete context
echo "=== Backend Protocol File (lines around 50-52) ===" && sed -n '40,65p' src/backend/types/protocol.ts

Repository: DagsHub/tmux-mobile

Length of output: 822


🏁 Script executed:

# Search for ControlClientMessage definition in both files
echo "=== Backend ControlClientMessage ===" && grep -A 10 "ControlClientMessage" src/backend/types/protocol.ts
echo ""
echo "=== Frontend ControlClientMessage ===" && grep -A 10 "ControlClientMessage" src/frontend/types/protocol.ts

Repository: DagsHub/tmux-mobile

Length of output: 725


Add ControlClientMessage type definition to the frontend protocol file.

The three new ControlServerMessage variants (auth_pending, auth_approved, auth_denied) are correctly synced between backend and frontend. However, the frontend protocol file is missing the entire ControlClientMessage type definition, which includes the required jwt?: string field. The frontend cannot properly type auth messages without this type.

Add the following to src/frontend/types/protocol.ts:

export type ControlClientMessage =
  | { type: "auth"; token?: string; password?: string; clientId?: string; jwt?: string }
  | { type: "select_session"; session: string }
  | { type: "new_session"; name: string }
  | { type: "new_window"; session: string }
  | { type: "select_window"; session: string; windowIndex: number }
  | { type: "kill_window"; session: string; windowIndex: number }
  | { type: "select_pane"; paneId: string }
  | { type: "split_pane"; paneId: string; orientation: "h" | "v" }
  | { type: "kill_pane"; paneId: string }
  | { type: "zoom_pane"; paneId: string }

Comment on lines +400 to +413
// Try JWT first if stored
const storedJwt = jwtRef.current;
if (storedJwt) {
openControlSocket("", storedJwt);
return;
}

// If password required and no password stored, show prompt
if (config.passwordRequired && !password) {
// If approval is enabled and no password, connect without password to trigger approval
if (config.approvalEnabled) {
openControlSocket("");
return;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stored JWT that is expired/revoked triggers a connection with no automatic fallback.

If jwtRef.current contains an expired JWT (line 402), openControlSocket("", storedJwt) is called. The server responds with auth_error, which clears the JWT (lines 338-341). However, the useEffect has already returned — there's no retry logic to re-run the connection attempt with password or approval flow after the JWT is cleared.

The user would see an error and the password prompt (if passwordRequired), but for approval-enabled servers without a password requirement, they'd be stuck with just an error message and no way to re-trigger the approval flow without a page refresh.

💡 Suggested approach — retry after JWT failure

In the auth_error handler (around line 338), after clearing the invalid JWT, automatically retry the connection:

          if (jwtValue) {
            jwtRef.current = null;
            localStorage.removeItem("tmux-mobile-jwt");
+           // Retry without JWT to trigger normal auth/approval flow
+           if (serverConfig?.approvalEnabled) {
+             openControlSocket("");
+             return;
+           }
          }

Note: be cautious of infinite retry loops — only retry once after JWT clearance.

Comment on lines +117 to +123
beforeEach(async () => {
await startServer({ password: "secret123" });
});

afterEach(async () => {
await runningServer.stop();
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

afterEach hook times out due to unclosed sockets from failing tests.

CI reports "Hook timed out in 10000ms" at the afterEach (line 121). When preceding test assertions fail, sockets may remain open, preventing runningServer.stop() from completing. Consider forcefully closing all tracked connections before stopping the server, or increase the hook timeout for integration tests.

🛠 Suggested approach — add a cleanup timeout
   afterEach(async () => {
-    await runningServer.stop();
+    // Force close to avoid hanging when tests leave sockets open
+    await Promise.race([
+      runningServer.stop(),
+      new Promise<void>((resolve) => setTimeout(() => resolve(), 5000))
+    ]);
   });

A better long-term fix is to track all opened sockets in the test and close them in afterEach before calling stop().

🧰 Tools
🪛 GitHub Check: test-and-build

[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > terminal WS authenticates with JWT
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3


[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > JWT reconnection skips password and approval
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3


[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > approving a pending connection sends auth_approved with JWT
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3


[failure] 121-121: tests/integration/approval-flow.test.ts > approval flow integration > password auth succeeds and issues JWT via auth_approved
Error: Hook timed out in 10000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
❯ tests/integration/approval-flow.test.ts:121:3

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