clawdex-mobile 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (526) hide show
  1. package/.github/workflows/ci.yml +77 -0
  2. package/.github/workflows/npm-release.yml +67 -0
  3. package/AGENTS.md +120 -0
  4. package/LICENSE +21 -0
  5. package/README.md +648 -0
  6. package/STATUS.md +115 -0
  7. package/apps/mobile/.env.example +7 -0
  8. package/apps/mobile/App.tsx +589 -0
  9. package/apps/mobile/app.json +33 -0
  10. package/apps/mobile/assets/brand/adaptive-icon.png +0 -0
  11. package/apps/mobile/assets/brand/app-icon.png +0 -0
  12. package/apps/mobile/assets/brand/favicon.png +0 -0
  13. package/apps/mobile/assets/brand/mark.png +0 -0
  14. package/apps/mobile/assets/brand/splash-icon.png +0 -0
  15. package/apps/mobile/babel.config.js +7 -0
  16. package/apps/mobile/eslint.config.cjs +28 -0
  17. package/apps/mobile/index.js +5 -0
  18. package/apps/mobile/metro.config.js +3 -0
  19. package/apps/mobile/package.json +55 -0
  20. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +132 -0
  21. package/apps/mobile/src/api/__tests__/client.test.ts +872 -0
  22. package/apps/mobile/src/api/__tests__/ws.test.ts +575 -0
  23. package/apps/mobile/src/api/chatMapping.ts +591 -0
  24. package/apps/mobile/src/api/client.ts +1087 -0
  25. package/apps/mobile/src/api/types.ts +267 -0
  26. package/apps/mobile/src/api/ws.ts +801 -0
  27. package/apps/mobile/src/components/ActivityBar.tsx +76 -0
  28. package/apps/mobile/src/components/ApprovalBanner.tsx +207 -0
  29. package/apps/mobile/src/components/BrandMark.tsx +43 -0
  30. package/apps/mobile/src/components/ChatHeader.tsx +106 -0
  31. package/apps/mobile/src/components/ChatInput.tsx +236 -0
  32. package/apps/mobile/src/components/ChatMessage.tsx +400 -0
  33. package/apps/mobile/src/components/StatusLine.tsx +52 -0
  34. package/apps/mobile/src/components/ToolBlock.tsx +65 -0
  35. package/apps/mobile/src/components/TypingIndicator.tsx +64 -0
  36. package/apps/mobile/src/config.ts +75 -0
  37. package/apps/mobile/src/navigation/DrawerContent.tsx +969 -0
  38. package/apps/mobile/src/screens/GitScreen.tsx +573 -0
  39. package/apps/mobile/src/screens/MainScreen.tsx +6905 -0
  40. package/apps/mobile/src/screens/PrivacyScreen.tsx +196 -0
  41. package/apps/mobile/src/screens/SettingsScreen.tsx +776 -0
  42. package/apps/mobile/src/screens/TerminalScreen.tsx +251 -0
  43. package/apps/mobile/src/screens/TermsScreen.tsx +192 -0
  44. package/apps/mobile/src/theme.ts +112 -0
  45. package/apps/mobile/src/types/assets.d.ts +4 -0
  46. package/apps/mobile/tsconfig.json +33 -0
  47. package/bin/clawdex.js +72 -0
  48. package/docs/app-review-notes.md +111 -0
  49. package/docs/open-source-license-requirements.md +32 -0
  50. package/docs/plans/2026-02-20-codex-desktop-style-redesign.md +190 -0
  51. package/docs/plans/2026-02-20-codex-mobile-implementation.md +1630 -0
  52. package/docs/plans/2026-02-21-codex-ui-redesign-design.md +101 -0
  53. package/docs/plans/2026-02-21-codex-ui-redesign.md +1229 -0
  54. package/docs/realtime-streaming-limitations.md +77 -0
  55. package/package.json +47 -0
  56. package/scripts/setup-secure-dev.sh +169 -0
  57. package/scripts/setup-wizard.sh +1302 -0
  58. package/scripts/start-bridge-secure.sh +38 -0
  59. package/scripts/start-expo.sh +176 -0
  60. package/scripts/stop-services.sh +61 -0
  61. package/scripts/teardown.sh +136 -0
  62. package/services/mac-bridge/.env.example +10 -0
  63. package/services/mac-bridge/codex-types/AbsolutePathBuf.ts +14 -0
  64. package/services/mac-bridge/codex-types/AddConversationListenerParams.ts +6 -0
  65. package/services/mac-bridge/codex-types/AddConversationSubscriptionResponse.ts +5 -0
  66. package/services/mac-bridge/codex-types/AgentMessageContent.ts +5 -0
  67. package/services/mac-bridge/codex-types/AgentMessageContentDeltaEvent.ts +5 -0
  68. package/services/mac-bridge/codex-types/AgentMessageDeltaEvent.ts +5 -0
  69. package/services/mac-bridge/codex-types/AgentMessageEvent.ts +5 -0
  70. package/services/mac-bridge/codex-types/AgentMessageItem.ts +21 -0
  71. package/services/mac-bridge/codex-types/AgentReasoningDeltaEvent.ts +5 -0
  72. package/services/mac-bridge/codex-types/AgentReasoningEvent.ts +5 -0
  73. package/services/mac-bridge/codex-types/AgentReasoningRawContentDeltaEvent.ts +5 -0
  74. package/services/mac-bridge/codex-types/AgentReasoningRawContentEvent.ts +5 -0
  75. package/services/mac-bridge/codex-types/AgentReasoningSectionBreakEvent.ts +5 -0
  76. package/services/mac-bridge/codex-types/AgentStatus.ts +8 -0
  77. package/services/mac-bridge/codex-types/ApplyPatchApprovalParams.ts +21 -0
  78. package/services/mac-bridge/codex-types/ApplyPatchApprovalRequestEvent.ts +23 -0
  79. package/services/mac-bridge/codex-types/ApplyPatchApprovalResponse.ts +6 -0
  80. package/services/mac-bridge/codex-types/ArchiveConversationParams.ts +6 -0
  81. package/services/mac-bridge/codex-types/ArchiveConversationResponse.ts +5 -0
  82. package/services/mac-bridge/codex-types/AskForApproval.ts +9 -0
  83. package/services/mac-bridge/codex-types/AuthMode.ts +8 -0
  84. package/services/mac-bridge/codex-types/AuthStatusChangeNotification.ts +9 -0
  85. package/services/mac-bridge/codex-types/BackgroundEventEvent.ts +5 -0
  86. package/services/mac-bridge/codex-types/ByteRange.ts +13 -0
  87. package/services/mac-bridge/codex-types/CallToolResult.ts +9 -0
  88. package/services/mac-bridge/codex-types/CancelLoginChatGptParams.ts +5 -0
  89. package/services/mac-bridge/codex-types/CancelLoginChatGptResponse.ts +5 -0
  90. package/services/mac-bridge/codex-types/ClientInfo.ts +5 -0
  91. package/services/mac-bridge/codex-types/ClientNotification.ts +5 -0
  92. package/services/mac-bridge/codex-types/ClientRequest.ts +60 -0
  93. package/services/mac-bridge/codex-types/CodexErrorInfo.ts +8 -0
  94. package/services/mac-bridge/codex-types/CollabAgentInteractionBeginEvent.ts +23 -0
  95. package/services/mac-bridge/codex-types/CollabAgentInteractionEndEvent.ts +28 -0
  96. package/services/mac-bridge/codex-types/CollabAgentSpawnBeginEvent.ts +19 -0
  97. package/services/mac-bridge/codex-types/CollabAgentSpawnEndEvent.ts +28 -0
  98. package/services/mac-bridge/codex-types/CollabCloseBeginEvent.ts +18 -0
  99. package/services/mac-bridge/codex-types/CollabCloseEndEvent.ts +24 -0
  100. package/services/mac-bridge/codex-types/CollabResumeBeginEvent.ts +18 -0
  101. package/services/mac-bridge/codex-types/CollabResumeEndEvent.ts +24 -0
  102. package/services/mac-bridge/codex-types/CollabWaitingBeginEvent.ts +18 -0
  103. package/services/mac-bridge/codex-types/CollabWaitingEndEvent.ts +19 -0
  104. package/services/mac-bridge/codex-types/CollaborationMode.ts +10 -0
  105. package/services/mac-bridge/codex-types/CollaborationModeMask.ts +11 -0
  106. package/services/mac-bridge/codex-types/ContentItem.ts +5 -0
  107. package/services/mac-bridge/codex-types/ContextCompactedEvent.ts +5 -0
  108. package/services/mac-bridge/codex-types/ContextCompactionItem.ts +5 -0
  109. package/services/mac-bridge/codex-types/ConversationGitInfo.ts +5 -0
  110. package/services/mac-bridge/codex-types/ConversationSummary.ts +8 -0
  111. package/services/mac-bridge/codex-types/CreditsSnapshot.ts +5 -0
  112. package/services/mac-bridge/codex-types/CustomPrompt.ts +5 -0
  113. package/services/mac-bridge/codex-types/DeprecationNoticeEvent.ts +13 -0
  114. package/services/mac-bridge/codex-types/DynamicToolCallRequest.ts +6 -0
  115. package/services/mac-bridge/codex-types/ElicitationRequestEvent.ts +5 -0
  116. package/services/mac-bridge/codex-types/ErrorEvent.ts +6 -0
  117. package/services/mac-bridge/codex-types/EventMsg.ts +78 -0
  118. package/services/mac-bridge/codex-types/ExecApprovalRequestEvent.ts +44 -0
  119. package/services/mac-bridge/codex-types/ExecCommandApprovalParams.ts +16 -0
  120. package/services/mac-bridge/codex-types/ExecCommandApprovalResponse.ts +6 -0
  121. package/services/mac-bridge/codex-types/ExecCommandBeginEvent.ts +35 -0
  122. package/services/mac-bridge/codex-types/ExecCommandEndEvent.ts +64 -0
  123. package/services/mac-bridge/codex-types/ExecCommandOutputDeltaEvent.ts +18 -0
  124. package/services/mac-bridge/codex-types/ExecCommandSource.ts +5 -0
  125. package/services/mac-bridge/codex-types/ExecCommandStatus.ts +5 -0
  126. package/services/mac-bridge/codex-types/ExecOneOffCommandParams.ts +6 -0
  127. package/services/mac-bridge/codex-types/ExecOneOffCommandResponse.ts +5 -0
  128. package/services/mac-bridge/codex-types/ExecOutputStream.ts +5 -0
  129. package/services/mac-bridge/codex-types/ExecPolicyAmendment.ts +12 -0
  130. package/services/mac-bridge/codex-types/ExitedReviewModeEvent.ts +6 -0
  131. package/services/mac-bridge/codex-types/FileChange.ts +5 -0
  132. package/services/mac-bridge/codex-types/ForcedLoginMethod.ts +5 -0
  133. package/services/mac-bridge/codex-types/ForkConversationParams.ts +7 -0
  134. package/services/mac-bridge/codex-types/ForkConversationResponse.ts +7 -0
  135. package/services/mac-bridge/codex-types/FunctionCallOutputBody.ts +6 -0
  136. package/services/mac-bridge/codex-types/FunctionCallOutputContentItem.ts +9 -0
  137. package/services/mac-bridge/codex-types/FunctionCallOutputPayload.ts +12 -0
  138. package/services/mac-bridge/codex-types/FuzzyFileSearchParams.ts +5 -0
  139. package/services/mac-bridge/codex-types/FuzzyFileSearchResponse.ts +6 -0
  140. package/services/mac-bridge/codex-types/FuzzyFileSearchResult.ts +8 -0
  141. package/services/mac-bridge/codex-types/FuzzyFileSearchSessionCompletedNotification.ts +5 -0
  142. package/services/mac-bridge/codex-types/FuzzyFileSearchSessionUpdatedNotification.ts +6 -0
  143. package/services/mac-bridge/codex-types/GetAuthStatusParams.ts +5 -0
  144. package/services/mac-bridge/codex-types/GetAuthStatusResponse.ts +6 -0
  145. package/services/mac-bridge/codex-types/GetConversationSummaryParams.ts +6 -0
  146. package/services/mac-bridge/codex-types/GetConversationSummaryResponse.ts +6 -0
  147. package/services/mac-bridge/codex-types/GetHistoryEntryResponseEvent.ts +10 -0
  148. package/services/mac-bridge/codex-types/GetUserAgentResponse.ts +5 -0
  149. package/services/mac-bridge/codex-types/GetUserSavedConfigResponse.ts +6 -0
  150. package/services/mac-bridge/codex-types/GhostCommit.ts +8 -0
  151. package/services/mac-bridge/codex-types/GitDiffToRemoteParams.ts +5 -0
  152. package/services/mac-bridge/codex-types/GitDiffToRemoteResponse.ts +6 -0
  153. package/services/mac-bridge/codex-types/GitSha.ts +5 -0
  154. package/services/mac-bridge/codex-types/HistoryEntry.ts +5 -0
  155. package/services/mac-bridge/codex-types/InitializeCapabilities.ts +17 -0
  156. package/services/mac-bridge/codex-types/InitializeParams.ts +7 -0
  157. package/services/mac-bridge/codex-types/InitializeResponse.ts +5 -0
  158. package/services/mac-bridge/codex-types/InputItem.ts +10 -0
  159. package/services/mac-bridge/codex-types/InputModality.ts +8 -0
  160. package/services/mac-bridge/codex-types/InterruptConversationParams.ts +6 -0
  161. package/services/mac-bridge/codex-types/InterruptConversationResponse.ts +6 -0
  162. package/services/mac-bridge/codex-types/ItemCompletedEvent.ts +7 -0
  163. package/services/mac-bridge/codex-types/ItemStartedEvent.ts +7 -0
  164. package/services/mac-bridge/codex-types/ListConversationsParams.ts +5 -0
  165. package/services/mac-bridge/codex-types/ListConversationsResponse.ts +6 -0
  166. package/services/mac-bridge/codex-types/ListCustomPromptsResponseEvent.ts +9 -0
  167. package/services/mac-bridge/codex-types/ListRemoteSkillsResponseEvent.ts +9 -0
  168. package/services/mac-bridge/codex-types/ListSkillsResponseEvent.ts +9 -0
  169. package/services/mac-bridge/codex-types/LocalShellAction.ts +6 -0
  170. package/services/mac-bridge/codex-types/LocalShellExecAction.ts +5 -0
  171. package/services/mac-bridge/codex-types/LocalShellStatus.ts +5 -0
  172. package/services/mac-bridge/codex-types/LoginApiKeyParams.ts +5 -0
  173. package/services/mac-bridge/codex-types/LoginApiKeyResponse.ts +5 -0
  174. package/services/mac-bridge/codex-types/LoginChatGptCompleteNotification.ts +8 -0
  175. package/services/mac-bridge/codex-types/LoginChatGptResponse.ts +5 -0
  176. package/services/mac-bridge/codex-types/LogoutChatGptResponse.ts +5 -0
  177. package/services/mac-bridge/codex-types/McpAuthStatus.ts +5 -0
  178. package/services/mac-bridge/codex-types/McpInvocation.ts +18 -0
  179. package/services/mac-bridge/codex-types/McpListToolsResponseEvent.ts +25 -0
  180. package/services/mac-bridge/codex-types/McpStartupCompleteEvent.ts +6 -0
  181. package/services/mac-bridge/codex-types/McpStartupFailure.ts +5 -0
  182. package/services/mac-bridge/codex-types/McpStartupStatus.ts +5 -0
  183. package/services/mac-bridge/codex-types/McpStartupUpdateEvent.ts +14 -0
  184. package/services/mac-bridge/codex-types/McpToolCallBeginEvent.ts +10 -0
  185. package/services/mac-bridge/codex-types/McpToolCallEndEvent.ts +15 -0
  186. package/services/mac-bridge/codex-types/MessagePhase.ts +11 -0
  187. package/services/mac-bridge/codex-types/ModeKind.ts +8 -0
  188. package/services/mac-bridge/codex-types/ModelRerouteEvent.ts +6 -0
  189. package/services/mac-bridge/codex-types/ModelRerouteReason.ts +5 -0
  190. package/services/mac-bridge/codex-types/NetworkAccess.ts +8 -0
  191. package/services/mac-bridge/codex-types/NetworkApprovalContext.ts +6 -0
  192. package/services/mac-bridge/codex-types/NetworkApprovalProtocol.ts +5 -0
  193. package/services/mac-bridge/codex-types/NewConversationParams.ts +8 -0
  194. package/services/mac-bridge/codex-types/NewConversationResponse.ts +7 -0
  195. package/services/mac-bridge/codex-types/ParsedCommand.ts +12 -0
  196. package/services/mac-bridge/codex-types/PatchApplyBeginEvent.ts +23 -0
  197. package/services/mac-bridge/codex-types/PatchApplyEndEvent.ts +36 -0
  198. package/services/mac-bridge/codex-types/PatchApplyStatus.ts +5 -0
  199. package/services/mac-bridge/codex-types/Personality.ts +5 -0
  200. package/services/mac-bridge/codex-types/PlanDeltaEvent.ts +5 -0
  201. package/services/mac-bridge/codex-types/PlanItem.ts +5 -0
  202. package/services/mac-bridge/codex-types/PlanItemArg.ts +6 -0
  203. package/services/mac-bridge/codex-types/PlanType.ts +5 -0
  204. package/services/mac-bridge/codex-types/Profile.ts +9 -0
  205. package/services/mac-bridge/codex-types/RateLimitSnapshot.ts +8 -0
  206. package/services/mac-bridge/codex-types/RateLimitWindow.ts +17 -0
  207. package/services/mac-bridge/codex-types/RawResponseItemEvent.ts +6 -0
  208. package/services/mac-bridge/codex-types/ReadOnlyAccess.ts +19 -0
  209. package/services/mac-bridge/codex-types/ReasoningContentDeltaEvent.ts +5 -0
  210. package/services/mac-bridge/codex-types/ReasoningEffort.ts +8 -0
  211. package/services/mac-bridge/codex-types/ReasoningItem.ts +5 -0
  212. package/services/mac-bridge/codex-types/ReasoningItemContent.ts +5 -0
  213. package/services/mac-bridge/codex-types/ReasoningItemReasoningSummary.ts +5 -0
  214. package/services/mac-bridge/codex-types/ReasoningRawContentDeltaEvent.ts +5 -0
  215. package/services/mac-bridge/codex-types/ReasoningSummary.ts +10 -0
  216. package/services/mac-bridge/codex-types/RemoteSkillDownloadedEvent.ts +8 -0
  217. package/services/mac-bridge/codex-types/RemoteSkillSummary.ts +5 -0
  218. package/services/mac-bridge/codex-types/RemoveConversationListenerParams.ts +5 -0
  219. package/services/mac-bridge/codex-types/RemoveConversationSubscriptionResponse.ts +5 -0
  220. package/services/mac-bridge/codex-types/RequestId.ts +5 -0
  221. package/services/mac-bridge/codex-types/RequestUserInputEvent.ts +15 -0
  222. package/services/mac-bridge/codex-types/RequestUserInputQuestion.ts +6 -0
  223. package/services/mac-bridge/codex-types/RequestUserInputQuestionOption.ts +5 -0
  224. package/services/mac-bridge/codex-types/Resource.ts +9 -0
  225. package/services/mac-bridge/codex-types/ResourceTemplate.ts +9 -0
  226. package/services/mac-bridge/codex-types/ResponseItem.ts +18 -0
  227. package/services/mac-bridge/codex-types/ResumeConversationParams.ts +8 -0
  228. package/services/mac-bridge/codex-types/ResumeConversationResponse.ts +7 -0
  229. package/services/mac-bridge/codex-types/ReviewCodeLocation.ts +9 -0
  230. package/services/mac-bridge/codex-types/ReviewDecision.ts +9 -0
  231. package/services/mac-bridge/codex-types/ReviewFinding.ts +9 -0
  232. package/services/mac-bridge/codex-types/ReviewLineRange.ts +8 -0
  233. package/services/mac-bridge/codex-types/ReviewOutputEvent.ts +9 -0
  234. package/services/mac-bridge/codex-types/ReviewRequest.ts +9 -0
  235. package/services/mac-bridge/codex-types/ReviewTarget.ts +9 -0
  236. package/services/mac-bridge/codex-types/SandboxMode.ts +5 -0
  237. package/services/mac-bridge/codex-types/SandboxPolicy.ts +44 -0
  238. package/services/mac-bridge/codex-types/SandboxSettings.ts +6 -0
  239. package/services/mac-bridge/codex-types/SendUserMessageParams.ts +7 -0
  240. package/services/mac-bridge/codex-types/SendUserMessageResponse.ts +5 -0
  241. package/services/mac-bridge/codex-types/SendUserTurnParams.ts +16 -0
  242. package/services/mac-bridge/codex-types/SendUserTurnResponse.ts +5 -0
  243. package/services/mac-bridge/codex-types/ServerNotification.ts +45 -0
  244. package/services/mac-bridge/codex-types/ServerRequest.ts +16 -0
  245. package/services/mac-bridge/codex-types/SessionConfiguredEvent.ts +57 -0
  246. package/services/mac-bridge/codex-types/SessionConfiguredNotification.ts +8 -0
  247. package/services/mac-bridge/codex-types/SessionNetworkProxyRuntime.ts +5 -0
  248. package/services/mac-bridge/codex-types/SessionSource.ts +6 -0
  249. package/services/mac-bridge/codex-types/SetDefaultModelParams.ts +6 -0
  250. package/services/mac-bridge/codex-types/SetDefaultModelResponse.ts +5 -0
  251. package/services/mac-bridge/codex-types/Settings.ts +9 -0
  252. package/services/mac-bridge/codex-types/SkillDependencies.ts +6 -0
  253. package/services/mac-bridge/codex-types/SkillErrorInfo.ts +5 -0
  254. package/services/mac-bridge/codex-types/SkillInterface.ts +5 -0
  255. package/services/mac-bridge/codex-types/SkillMetadata.ts +12 -0
  256. package/services/mac-bridge/codex-types/SkillScope.ts +5 -0
  257. package/services/mac-bridge/codex-types/SkillToolDependency.ts +5 -0
  258. package/services/mac-bridge/codex-types/SkillsListEntry.ts +7 -0
  259. package/services/mac-bridge/codex-types/StepStatus.ts +5 -0
  260. package/services/mac-bridge/codex-types/StreamErrorEvent.ts +12 -0
  261. package/services/mac-bridge/codex-types/SubAgentSource.ts +6 -0
  262. package/services/mac-bridge/codex-types/TerminalInteractionEvent.ts +17 -0
  263. package/services/mac-bridge/codex-types/TextElement.ts +14 -0
  264. package/services/mac-bridge/codex-types/ThreadId.ts +5 -0
  265. package/services/mac-bridge/codex-types/ThreadNameUpdatedEvent.ts +6 -0
  266. package/services/mac-bridge/codex-types/ThreadRolledBackEvent.ts +9 -0
  267. package/services/mac-bridge/codex-types/TokenCountEvent.ts +7 -0
  268. package/services/mac-bridge/codex-types/TokenUsage.ts +5 -0
  269. package/services/mac-bridge/codex-types/TokenUsageInfo.ts +6 -0
  270. package/services/mac-bridge/codex-types/Tool.ts +9 -0
  271. package/services/mac-bridge/codex-types/Tools.ts +5 -0
  272. package/services/mac-bridge/codex-types/TurnAbortReason.ts +5 -0
  273. package/services/mac-bridge/codex-types/TurnAbortedEvent.ts +6 -0
  274. package/services/mac-bridge/codex-types/TurnCompleteEvent.ts +5 -0
  275. package/services/mac-bridge/codex-types/TurnDiffEvent.ts +5 -0
  276. package/services/mac-bridge/codex-types/TurnItem.ts +11 -0
  277. package/services/mac-bridge/codex-types/TurnStartedEvent.ts +6 -0
  278. package/services/mac-bridge/codex-types/UndoCompletedEvent.ts +5 -0
  279. package/services/mac-bridge/codex-types/UndoStartedEvent.ts +5 -0
  280. package/services/mac-bridge/codex-types/UpdatePlanArgs.ts +10 -0
  281. package/services/mac-bridge/codex-types/UserInfoResponse.ts +5 -0
  282. package/services/mac-bridge/codex-types/UserInput.ts +16 -0
  283. package/services/mac-bridge/codex-types/UserMessageEvent.ts +22 -0
  284. package/services/mac-bridge/codex-types/UserMessageItem.ts +6 -0
  285. package/services/mac-bridge/codex-types/UserSavedConfig.ts +14 -0
  286. package/services/mac-bridge/codex-types/Verbosity.ts +9 -0
  287. package/services/mac-bridge/codex-types/ViewImageToolCallEvent.ts +13 -0
  288. package/services/mac-bridge/codex-types/WarningEvent.ts +5 -0
  289. package/services/mac-bridge/codex-types/WebSearchAction.ts +5 -0
  290. package/services/mac-bridge/codex-types/WebSearchBeginEvent.ts +5 -0
  291. package/services/mac-bridge/codex-types/WebSearchEndEvent.ts +6 -0
  292. package/services/mac-bridge/codex-types/WebSearchItem.ts +6 -0
  293. package/services/mac-bridge/codex-types/WebSearchMode.ts +5 -0
  294. package/services/mac-bridge/codex-types/index.ts +234 -0
  295. package/services/mac-bridge/codex-types/serde_json/JsonValue.ts +5 -0
  296. package/services/mac-bridge/codex-types/v2/Account.ts +6 -0
  297. package/services/mac-bridge/codex-types/v2/AccountLoginCompletedNotification.ts +5 -0
  298. package/services/mac-bridge/codex-types/v2/AccountRateLimitsUpdatedNotification.ts +6 -0
  299. package/services/mac-bridge/codex-types/v2/AccountUpdatedNotification.ts +6 -0
  300. package/services/mac-bridge/codex-types/v2/AgentMessageDeltaNotification.ts +5 -0
  301. package/services/mac-bridge/codex-types/v2/AnalyticsConfig.ts +6 -0
  302. package/services/mac-bridge/codex-types/v2/AppBranding.ts +8 -0
  303. package/services/mac-bridge/codex-types/v2/AppDisabledReason.ts +5 -0
  304. package/services/mac-bridge/codex-types/v2/AppInfo.ts +19 -0
  305. package/services/mac-bridge/codex-types/v2/AppListUpdatedNotification.ts +9 -0
  306. package/services/mac-bridge/codex-types/v2/AppMetadata.ts +7 -0
  307. package/services/mac-bridge/codex-types/v2/AppReview.ts +5 -0
  308. package/services/mac-bridge/codex-types/v2/AppScreenshot.ts +5 -0
  309. package/services/mac-bridge/codex-types/v2/AppsConfig.ts +6 -0
  310. package/services/mac-bridge/codex-types/v2/AppsListParams.ts +24 -0
  311. package/services/mac-bridge/codex-types/v2/AppsListResponse.ts +14 -0
  312. package/services/mac-bridge/codex-types/v2/AskForApproval.ts +5 -0
  313. package/services/mac-bridge/codex-types/v2/ByteRange.ts +5 -0
  314. package/services/mac-bridge/codex-types/v2/CancelLoginAccountParams.ts +5 -0
  315. package/services/mac-bridge/codex-types/v2/CancelLoginAccountResponse.ts +6 -0
  316. package/services/mac-bridge/codex-types/v2/CancelLoginAccountStatus.ts +5 -0
  317. package/services/mac-bridge/codex-types/v2/ChatgptAuthTokensRefreshParams.ts +16 -0
  318. package/services/mac-bridge/codex-types/v2/ChatgptAuthTokensRefreshReason.ts +5 -0
  319. package/services/mac-bridge/codex-types/v2/ChatgptAuthTokensRefreshResponse.ts +5 -0
  320. package/services/mac-bridge/codex-types/v2/CodexErrorInfo.ts +11 -0
  321. package/services/mac-bridge/codex-types/v2/CollabAgentState.ts +6 -0
  322. package/services/mac-bridge/codex-types/v2/CollabAgentStatus.ts +5 -0
  323. package/services/mac-bridge/codex-types/v2/CollabAgentTool.ts +5 -0
  324. package/services/mac-bridge/codex-types/v2/CollabAgentToolCallStatus.ts +5 -0
  325. package/services/mac-bridge/codex-types/v2/CommandAction.ts +5 -0
  326. package/services/mac-bridge/codex-types/v2/CommandExecParams.ts +6 -0
  327. package/services/mac-bridge/codex-types/v2/CommandExecResponse.ts +5 -0
  328. package/services/mac-bridge/codex-types/v2/CommandExecutionApprovalDecision.ts +6 -0
  329. package/services/mac-bridge/codex-types/v2/CommandExecutionOutputDeltaNotification.ts +5 -0
  330. package/services/mac-bridge/codex-types/v2/CommandExecutionRequestApprovalParams.ts +37 -0
  331. package/services/mac-bridge/codex-types/v2/CommandExecutionRequestApprovalResponse.ts +6 -0
  332. package/services/mac-bridge/codex-types/v2/CommandExecutionStatus.ts +5 -0
  333. package/services/mac-bridge/codex-types/v2/Config.ts +17 -0
  334. package/services/mac-bridge/codex-types/v2/ConfigBatchWriteParams.ts +10 -0
  335. package/services/mac-bridge/codex-types/v2/ConfigEdit.ts +7 -0
  336. package/services/mac-bridge/codex-types/v2/ConfigLayer.ts +7 -0
  337. package/services/mac-bridge/codex-types/v2/ConfigLayerMetadata.ts +6 -0
  338. package/services/mac-bridge/codex-types/v2/ConfigLayerSource.ts +16 -0
  339. package/services/mac-bridge/codex-types/v2/ConfigReadParams.ts +11 -0
  340. package/services/mac-bridge/codex-types/v2/ConfigReadResponse.ts +8 -0
  341. package/services/mac-bridge/codex-types/v2/ConfigRequirements.ts +9 -0
  342. package/services/mac-bridge/codex-types/v2/ConfigRequirementsReadResponse.ts +10 -0
  343. package/services/mac-bridge/codex-types/v2/ConfigValueWriteParams.ts +11 -0
  344. package/services/mac-bridge/codex-types/v2/ConfigWarningNotification.ts +22 -0
  345. package/services/mac-bridge/codex-types/v2/ConfigWriteResponse.ts +12 -0
  346. package/services/mac-bridge/codex-types/v2/ContextCompactedNotification.ts +8 -0
  347. package/services/mac-bridge/codex-types/v2/CreditsSnapshot.ts +5 -0
  348. package/services/mac-bridge/codex-types/v2/DeprecationNoticeNotification.ts +13 -0
  349. package/services/mac-bridge/codex-types/v2/DynamicToolCallOutputContentItem.ts +5 -0
  350. package/services/mac-bridge/codex-types/v2/DynamicToolCallParams.ts +6 -0
  351. package/services/mac-bridge/codex-types/v2/DynamicToolCallResponse.ts +6 -0
  352. package/services/mac-bridge/codex-types/v2/DynamicToolSpec.ts +6 -0
  353. package/services/mac-bridge/codex-types/v2/ErrorNotification.ts +6 -0
  354. package/services/mac-bridge/codex-types/v2/ExecPolicyAmendment.ts +5 -0
  355. package/services/mac-bridge/codex-types/v2/ExperimentalFeature.ts +37 -0
  356. package/services/mac-bridge/codex-types/v2/ExperimentalFeatureListParams.ts +13 -0
  357. package/services/mac-bridge/codex-types/v2/ExperimentalFeatureListResponse.ts +11 -0
  358. package/services/mac-bridge/codex-types/v2/ExperimentalFeatureStage.ts +5 -0
  359. package/services/mac-bridge/codex-types/v2/FeedbackUploadParams.ts +5 -0
  360. package/services/mac-bridge/codex-types/v2/FeedbackUploadResponse.ts +5 -0
  361. package/services/mac-bridge/codex-types/v2/FileChangeApprovalDecision.ts +5 -0
  362. package/services/mac-bridge/codex-types/v2/FileChangeOutputDeltaNotification.ts +5 -0
  363. package/services/mac-bridge/codex-types/v2/FileChangeRequestApprovalParams.ts +14 -0
  364. package/services/mac-bridge/codex-types/v2/FileChangeRequestApprovalResponse.ts +6 -0
  365. package/services/mac-bridge/codex-types/v2/FileUpdateChange.ts +6 -0
  366. package/services/mac-bridge/codex-types/v2/GetAccountParams.ts +13 -0
  367. package/services/mac-bridge/codex-types/v2/GetAccountRateLimitsResponse.ts +14 -0
  368. package/services/mac-bridge/codex-types/v2/GetAccountResponse.ts +6 -0
  369. package/services/mac-bridge/codex-types/v2/GitInfo.ts +5 -0
  370. package/services/mac-bridge/codex-types/v2/HazelnutScope.ts +5 -0
  371. package/services/mac-bridge/codex-types/v2/ItemCompletedNotification.ts +6 -0
  372. package/services/mac-bridge/codex-types/v2/ItemStartedNotification.ts +6 -0
  373. package/services/mac-bridge/codex-types/v2/ListMcpServerStatusParams.ts +13 -0
  374. package/services/mac-bridge/codex-types/v2/ListMcpServerStatusResponse.ts +11 -0
  375. package/services/mac-bridge/codex-types/v2/LoginAccountParams.ts +21 -0
  376. package/services/mac-bridge/codex-types/v2/LoginAccountResponse.ts +9 -0
  377. package/services/mac-bridge/codex-types/v2/LogoutAccountResponse.ts +5 -0
  378. package/services/mac-bridge/codex-types/v2/McpAuthStatus.ts +5 -0
  379. package/services/mac-bridge/codex-types/v2/McpServerOauthLoginCompletedNotification.ts +5 -0
  380. package/services/mac-bridge/codex-types/v2/McpServerOauthLoginParams.ts +5 -0
  381. package/services/mac-bridge/codex-types/v2/McpServerOauthLoginResponse.ts +5 -0
  382. package/services/mac-bridge/codex-types/v2/McpServerRefreshResponse.ts +5 -0
  383. package/services/mac-bridge/codex-types/v2/McpServerStatus.ts +9 -0
  384. package/services/mac-bridge/codex-types/v2/McpToolCallError.ts +5 -0
  385. package/services/mac-bridge/codex-types/v2/McpToolCallProgressNotification.ts +5 -0
  386. package/services/mac-bridge/codex-types/v2/McpToolCallResult.ts +6 -0
  387. package/services/mac-bridge/codex-types/v2/McpToolCallStatus.ts +5 -0
  388. package/services/mac-bridge/codex-types/v2/MergeStrategy.ts +5 -0
  389. package/services/mac-bridge/codex-types/v2/Model.ts +8 -0
  390. package/services/mac-bridge/codex-types/v2/ModelListParams.ts +17 -0
  391. package/services/mac-bridge/codex-types/v2/ModelListResponse.ts +11 -0
  392. package/services/mac-bridge/codex-types/v2/ModelRerouteReason.ts +5 -0
  393. package/services/mac-bridge/codex-types/v2/ModelReroutedNotification.ts +6 -0
  394. package/services/mac-bridge/codex-types/v2/NetworkAccess.ts +5 -0
  395. package/services/mac-bridge/codex-types/v2/NetworkRequirements.ts +5 -0
  396. package/services/mac-bridge/codex-types/v2/OverriddenMetadata.ts +7 -0
  397. package/services/mac-bridge/codex-types/v2/PatchApplyStatus.ts +5 -0
  398. package/services/mac-bridge/codex-types/v2/PatchChangeKind.ts +5 -0
  399. package/services/mac-bridge/codex-types/v2/PlanDeltaNotification.ts +9 -0
  400. package/services/mac-bridge/codex-types/v2/ProductSurface.ts +5 -0
  401. package/services/mac-bridge/codex-types/v2/ProfileV2.ts +11 -0
  402. package/services/mac-bridge/codex-types/v2/RateLimitSnapshot.ts +8 -0
  403. package/services/mac-bridge/codex-types/v2/RateLimitWindow.ts +5 -0
  404. package/services/mac-bridge/codex-types/v2/RawResponseItemCompletedNotification.ts +6 -0
  405. package/services/mac-bridge/codex-types/v2/ReadOnlyAccess.ts +6 -0
  406. package/services/mac-bridge/codex-types/v2/ReasoningEffortOption.ts +6 -0
  407. package/services/mac-bridge/codex-types/v2/ReasoningSummaryPartAddedNotification.ts +5 -0
  408. package/services/mac-bridge/codex-types/v2/ReasoningSummaryTextDeltaNotification.ts +5 -0
  409. package/services/mac-bridge/codex-types/v2/ReasoningTextDeltaNotification.ts +5 -0
  410. package/services/mac-bridge/codex-types/v2/RemoteSkillSummary.ts +5 -0
  411. package/services/mac-bridge/codex-types/v2/ResidencyRequirement.ts +5 -0
  412. package/services/mac-bridge/codex-types/v2/ReviewDelivery.ts +5 -0
  413. package/services/mac-bridge/codex-types/v2/ReviewStartParams.ts +12 -0
  414. package/services/mac-bridge/codex-types/v2/ReviewStartResponse.ts +13 -0
  415. package/services/mac-bridge/codex-types/v2/ReviewTarget.ts +9 -0
  416. package/services/mac-bridge/codex-types/v2/SandboxMode.ts +5 -0
  417. package/services/mac-bridge/codex-types/v2/SandboxPolicy.ts +8 -0
  418. package/services/mac-bridge/codex-types/v2/SandboxWorkspaceWrite.ts +5 -0
  419. package/services/mac-bridge/codex-types/v2/SessionSource.ts +6 -0
  420. package/services/mac-bridge/codex-types/v2/SkillDependencies.ts +6 -0
  421. package/services/mac-bridge/codex-types/v2/SkillErrorInfo.ts +5 -0
  422. package/services/mac-bridge/codex-types/v2/SkillInterface.ts +5 -0
  423. package/services/mac-bridge/codex-types/v2/SkillMetadata.ts +12 -0
  424. package/services/mac-bridge/codex-types/v2/SkillScope.ts +5 -0
  425. package/services/mac-bridge/codex-types/v2/SkillToolDependency.ts +5 -0
  426. package/services/mac-bridge/codex-types/v2/SkillsConfigWriteParams.ts +5 -0
  427. package/services/mac-bridge/codex-types/v2/SkillsConfigWriteResponse.ts +5 -0
  428. package/services/mac-bridge/codex-types/v2/SkillsListEntry.ts +7 -0
  429. package/services/mac-bridge/codex-types/v2/SkillsListExtraRootsForCwd.ts +5 -0
  430. package/services/mac-bridge/codex-types/v2/SkillsListParams.ts +18 -0
  431. package/services/mac-bridge/codex-types/v2/SkillsListResponse.ts +6 -0
  432. package/services/mac-bridge/codex-types/v2/SkillsRemoteReadParams.ts +7 -0
  433. package/services/mac-bridge/codex-types/v2/SkillsRemoteReadResponse.ts +6 -0
  434. package/services/mac-bridge/codex-types/v2/SkillsRemoteWriteParams.ts +5 -0
  435. package/services/mac-bridge/codex-types/v2/SkillsRemoteWriteResponse.ts +5 -0
  436. package/services/mac-bridge/codex-types/v2/TerminalInteractionNotification.ts +5 -0
  437. package/services/mac-bridge/codex-types/v2/TextElement.ts +14 -0
  438. package/services/mac-bridge/codex-types/v2/TextPosition.ts +13 -0
  439. package/services/mac-bridge/codex-types/v2/TextRange.ts +6 -0
  440. package/services/mac-bridge/codex-types/v2/Thread.ts +51 -0
  441. package/services/mac-bridge/codex-types/v2/ThreadArchiveParams.ts +5 -0
  442. package/services/mac-bridge/codex-types/v2/ThreadArchiveResponse.ts +5 -0
  443. package/services/mac-bridge/codex-types/v2/ThreadArchivedNotification.ts +5 -0
  444. package/services/mac-bridge/codex-types/v2/ThreadCompactStartParams.ts +5 -0
  445. package/services/mac-bridge/codex-types/v2/ThreadCompactStartResponse.ts +5 -0
  446. package/services/mac-bridge/codex-types/v2/ThreadForkParams.ts +28 -0
  447. package/services/mac-bridge/codex-types/v2/ThreadForkResponse.ts +9 -0
  448. package/services/mac-bridge/codex-types/v2/ThreadItem.ts +81 -0
  449. package/services/mac-bridge/codex-types/v2/ThreadListParams.ts +39 -0
  450. package/services/mac-bridge/codex-types/v2/ThreadListResponse.ts +11 -0
  451. package/services/mac-bridge/codex-types/v2/ThreadLoadedListParams.ts +13 -0
  452. package/services/mac-bridge/codex-types/v2/ThreadLoadedListResponse.ts +14 -0
  453. package/services/mac-bridge/codex-types/v2/ThreadNameUpdatedNotification.ts +5 -0
  454. package/services/mac-bridge/codex-types/v2/ThreadReadParams.ts +9 -0
  455. package/services/mac-bridge/codex-types/v2/ThreadReadResponse.ts +6 -0
  456. package/services/mac-bridge/codex-types/v2/ThreadResumeParams.ts +37 -0
  457. package/services/mac-bridge/codex-types/v2/ThreadResumeResponse.ts +9 -0
  458. package/services/mac-bridge/codex-types/v2/ThreadRollbackParams.ts +12 -0
  459. package/services/mac-bridge/codex-types/v2/ThreadRollbackResponse.ts +14 -0
  460. package/services/mac-bridge/codex-types/v2/ThreadSetNameParams.ts +5 -0
  461. package/services/mac-bridge/codex-types/v2/ThreadSetNameResponse.ts +5 -0
  462. package/services/mac-bridge/codex-types/v2/ThreadSortKey.ts +5 -0
  463. package/services/mac-bridge/codex-types/v2/ThreadSourceKind.ts +5 -0
  464. package/services/mac-bridge/codex-types/v2/ThreadStartParams.ts +17 -0
  465. package/services/mac-bridge/codex-types/v2/ThreadStartResponse.ts +9 -0
  466. package/services/mac-bridge/codex-types/v2/ThreadStartedNotification.ts +6 -0
  467. package/services/mac-bridge/codex-types/v2/ThreadTokenUsage.ts +6 -0
  468. package/services/mac-bridge/codex-types/v2/ThreadTokenUsageUpdatedNotification.ts +6 -0
  469. package/services/mac-bridge/codex-types/v2/ThreadUnarchiveParams.ts +5 -0
  470. package/services/mac-bridge/codex-types/v2/ThreadUnarchiveResponse.ts +6 -0
  471. package/services/mac-bridge/codex-types/v2/ThreadUnarchivedNotification.ts +5 -0
  472. package/services/mac-bridge/codex-types/v2/TokenUsageBreakdown.ts +5 -0
  473. package/services/mac-bridge/codex-types/v2/ToolRequestUserInputAnswer.ts +8 -0
  474. package/services/mac-bridge/codex-types/v2/ToolRequestUserInputOption.ts +8 -0
  475. package/services/mac-bridge/codex-types/v2/ToolRequestUserInputParams.ts +9 -0
  476. package/services/mac-bridge/codex-types/v2/ToolRequestUserInputQuestion.ts +9 -0
  477. package/services/mac-bridge/codex-types/v2/ToolRequestUserInputResponse.ts +9 -0
  478. package/services/mac-bridge/codex-types/v2/ToolsV2.ts +5 -0
  479. package/services/mac-bridge/codex-types/v2/Turn.ts +18 -0
  480. package/services/mac-bridge/codex-types/v2/TurnCompletedNotification.ts +6 -0
  481. package/services/mac-bridge/codex-types/v2/TurnDiffUpdatedNotification.ts +9 -0
  482. package/services/mac-bridge/codex-types/v2/TurnError.ts +6 -0
  483. package/services/mac-bridge/codex-types/v2/TurnInterruptParams.ts +5 -0
  484. package/services/mac-bridge/codex-types/v2/TurnInterruptResponse.ts +5 -0
  485. package/services/mac-bridge/codex-types/v2/TurnPlanStep.ts +6 -0
  486. package/services/mac-bridge/codex-types/v2/TurnPlanStepStatus.ts +5 -0
  487. package/services/mac-bridge/codex-types/v2/TurnPlanUpdatedNotification.ts +6 -0
  488. package/services/mac-bridge/codex-types/v2/TurnStartParams.ts +44 -0
  489. package/services/mac-bridge/codex-types/v2/TurnStartResponse.ts +6 -0
  490. package/services/mac-bridge/codex-types/v2/TurnStartedNotification.ts +6 -0
  491. package/services/mac-bridge/codex-types/v2/TurnStatus.ts +5 -0
  492. package/services/mac-bridge/codex-types/v2/TurnSteerParams.ts +11 -0
  493. package/services/mac-bridge/codex-types/v2/TurnSteerResponse.ts +5 -0
  494. package/services/mac-bridge/codex-types/v2/UserInput.ts +10 -0
  495. package/services/mac-bridge/codex-types/v2/WebSearchAction.ts +5 -0
  496. package/services/mac-bridge/codex-types/v2/WindowsWorldWritableWarningNotification.ts +5 -0
  497. package/services/mac-bridge/codex-types/v2/WriteStatus.ts +5 -0
  498. package/services/mac-bridge/codex-types/v2/index.ts +204 -0
  499. package/services/mac-bridge/eslint.config.cjs +22 -0
  500. package/services/mac-bridge/package.json +30 -0
  501. package/services/mac-bridge/schema.ts +0 -0
  502. package/services/mac-bridge/src/index.ts +18 -0
  503. package/services/mac-bridge/src/server.ts +426 -0
  504. package/services/mac-bridge/src/services/__tests__/gitService.test.ts +157 -0
  505. package/services/mac-bridge/src/services/__tests__/realtimeHub.test.ts +116 -0
  506. package/services/mac-bridge/src/services/__tests__/terminalService.test.ts +51 -0
  507. package/services/mac-bridge/src/services/codexAppServerClient.ts +507 -0
  508. package/services/mac-bridge/src/services/codexCliAdapter.ts +622 -0
  509. package/services/mac-bridge/src/services/gitService.ts +61 -0
  510. package/services/mac-bridge/src/services/realtimeHub.ts +25 -0
  511. package/services/mac-bridge/src/services/terminalService.ts +226 -0
  512. package/services/mac-bridge/src/types.ts +151 -0
  513. package/services/mac-bridge/src/utils/__tests__/threadMapping.test.ts +397 -0
  514. package/services/mac-bridge/src/utils/threadMapping.ts +176 -0
  515. package/services/mac-bridge/tsconfig.json +16 -0
  516. package/services/mac-bridge/vitest.config.ts +9 -0
  517. package/services/rust-bridge/.env.example +11 -0
  518. package/services/rust-bridge/Cargo.lock +1127 -0
  519. package/services/rust-bridge/Cargo.toml +14 -0
  520. package/services/rust-bridge/package.json +13 -0
  521. package/services/rust-bridge/security_best_practices_report.md +24 -0
  522. package/services/rust-bridge/src/main.rs +2713 -0
  523. package/services/rust-bridge/src/services/git.rs +271 -0
  524. package/services/rust-bridge/src/services/mod.rs +5 -0
  525. package/services/rust-bridge/src/services/terminal.rs +267 -0
  526. package/tsconfig.json +4 -0
@@ -0,0 +1,2713 @@
1
+ use std::{
2
+ collections::{HashMap, HashSet, VecDeque},
3
+ env,
4
+ path::{Component, Path, PathBuf},
5
+ process::Stdio,
6
+ sync::{
7
+ atomic::{AtomicU64, Ordering},
8
+ Arc,
9
+ },
10
+ time::{Duration, Instant},
11
+ };
12
+
13
+ use axum::{
14
+ extract::{
15
+ ws::{Message, WebSocket, WebSocketUpgrade},
16
+ Query, State,
17
+ },
18
+ http::{HeaderMap, StatusCode},
19
+ response::{IntoResponse, Response},
20
+ routing::get,
21
+ Json, Router,
22
+ };
23
+ use base64::{engine::general_purpose, Engine as _};
24
+ use chrono::Utc;
25
+ use futures_util::{SinkExt, StreamExt};
26
+ use serde::{Deserialize, Serialize};
27
+ use serde_json::{json, Value};
28
+ use services::{GitService, TerminalService};
29
+ use tokio::{
30
+ fs,
31
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
32
+ process::{Child, ChildStdin, ChildStdout, Command},
33
+ sync::{mpsc, oneshot, Mutex, RwLock},
34
+ time::timeout,
35
+ };
36
+
37
+ mod services;
38
+
39
+ const APPROVAL_COMMAND_METHOD: &str = "item/commandExecution/requestApproval";
40
+ const APPROVAL_FILE_METHOD: &str = "item/fileChange/requestApproval";
41
+ const REQUEST_USER_INPUT_METHOD: &str = "item/tool/requestUserInput";
42
+ const REQUEST_USER_INPUT_METHOD_ALT: &str = "tool/requestUserInput";
43
+ const MOBILE_ATTACHMENTS_DIR: &str = ".clawdex-mobile-attachments";
44
+ const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
45
+ const NOTIFICATION_REPLAY_BUFFER_SIZE: usize = 2_000;
46
+ const NOTIFICATION_REPLAY_MAX_LIMIT: usize = 1_000;
47
+ const WS_CLIENT_QUEUE_CAPACITY: usize = 256;
48
+
49
+ #[derive(Clone)]
50
+ struct BridgeConfig {
51
+ host: String,
52
+ port: u16,
53
+ workdir: PathBuf,
54
+ cli_bin: String,
55
+ auth_token: Option<String>,
56
+ auth_enabled: bool,
57
+ allow_insecure_no_auth: bool,
58
+ allow_query_token_auth: bool,
59
+ allow_outside_root_cwd: bool,
60
+ disable_terminal_exec: bool,
61
+ terminal_allowed_commands: HashSet<String>,
62
+ }
63
+
64
+ impl BridgeConfig {
65
+ fn from_env() -> Result<Self, String> {
66
+ let host = env::var("BRIDGE_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
67
+ let port = env::var("BRIDGE_PORT")
68
+ .ok()
69
+ .and_then(|v| v.parse::<u16>().ok())
70
+ .unwrap_or(8787);
71
+
72
+ let configured_workdir = env::var("BRIDGE_WORKDIR")
73
+ .map(PathBuf::from)
74
+ .unwrap_or_else(|_| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
75
+ let workdir = resolve_bridge_workdir(configured_workdir)?;
76
+
77
+ let cli_bin = env::var("CODEX_CLI_BIN").unwrap_or_else(|_| "codex".to_string());
78
+ let auth_token = env::var("BRIDGE_AUTH_TOKEN")
79
+ .ok()
80
+ .map(|v| v.trim().to_string())
81
+ .filter(|v| !v.is_empty());
82
+
83
+ let allow_insecure_no_auth = parse_bool_env("BRIDGE_ALLOW_INSECURE_NO_AUTH");
84
+ if auth_token.is_none() && !allow_insecure_no_auth {
85
+ return Err(
86
+ "BRIDGE_AUTH_TOKEN is required. Set BRIDGE_ALLOW_INSECURE_NO_AUTH=true only for local development."
87
+ .to_string(),
88
+ );
89
+ }
90
+
91
+ let auth_enabled = auth_token.is_some();
92
+ let allow_query_token_auth = parse_bool_env("BRIDGE_ALLOW_QUERY_TOKEN_AUTH");
93
+ let allow_outside_root_cwd =
94
+ parse_bool_env_with_default("BRIDGE_ALLOW_OUTSIDE_ROOT_CWD", true);
95
+ let disable_terminal_exec = parse_bool_env("BRIDGE_DISABLE_TERMINAL_EXEC");
96
+
97
+ let terminal_allowed_commands = parse_csv_env(
98
+ "BRIDGE_TERMINAL_ALLOWED_COMMANDS",
99
+ &["pwd", "ls", "cat", "git"],
100
+ );
101
+
102
+ Ok(Self {
103
+ host,
104
+ port,
105
+ workdir,
106
+ cli_bin,
107
+ auth_token,
108
+ auth_enabled,
109
+ allow_insecure_no_auth,
110
+ allow_query_token_auth,
111
+ allow_outside_root_cwd,
112
+ disable_terminal_exec,
113
+ terminal_allowed_commands,
114
+ })
115
+ }
116
+
117
+ fn is_authorized(&self, headers: &HeaderMap, query_token: Option<&str>) -> bool {
118
+ if !self.auth_enabled {
119
+ return true;
120
+ }
121
+
122
+ let expected = match &self.auth_token {
123
+ Some(token) => token,
124
+ None => return false,
125
+ };
126
+
127
+ if let Some(value) = headers.get("authorization") {
128
+ if let Ok(raw) = value.to_str() {
129
+ let mut parts = raw.trim().split_whitespace();
130
+ let scheme = parts.next();
131
+ let token = parts.next();
132
+ if let (Some(scheme), Some(token)) = (scheme, token) {
133
+ if scheme.eq_ignore_ascii_case("bearer")
134
+ && parts.next().is_none()
135
+ && constant_time_eq(token, expected)
136
+ {
137
+ return true;
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ if self.allow_query_token_auth {
144
+ if let Some(token) = query_token.map(str::trim).filter(|token| !token.is_empty()) {
145
+ if constant_time_eq(token, expected) {
146
+ return true;
147
+ }
148
+ }
149
+ }
150
+
151
+ false
152
+ }
153
+ }
154
+
155
+ #[derive(Clone)]
156
+ struct AppState {
157
+ config: Arc<BridgeConfig>,
158
+ started_at: Instant,
159
+ hub: Arc<ClientHub>,
160
+ app_server: Arc<AppServerBridge>,
161
+ terminal: Arc<TerminalService>,
162
+ git: Arc<GitService>,
163
+ }
164
+
165
+ struct ClientHub {
166
+ next_client_id: AtomicU64,
167
+ next_event_id: AtomicU64,
168
+ replay_capacity: usize,
169
+ clients: RwLock<HashMap<u64, mpsc::Sender<Message>>>,
170
+ notification_replay: RwLock<VecDeque<ReplayableNotification>>,
171
+ }
172
+
173
+ #[derive(Clone)]
174
+ struct ReplayableNotification {
175
+ event_id: u64,
176
+ payload: Value,
177
+ }
178
+
179
+ impl ClientHub {
180
+ fn new() -> Self {
181
+ Self::with_replay_capacity(NOTIFICATION_REPLAY_BUFFER_SIZE)
182
+ }
183
+
184
+ fn with_replay_capacity(replay_capacity: usize) -> Self {
185
+ Self {
186
+ next_client_id: AtomicU64::new(1),
187
+ next_event_id: AtomicU64::new(1),
188
+ replay_capacity,
189
+ clients: RwLock::new(HashMap::new()),
190
+ notification_replay: RwLock::new(VecDeque::new()),
191
+ }
192
+ }
193
+
194
+ async fn add_client(&self, tx: mpsc::Sender<Message>) -> u64 {
195
+ let id = self.next_client_id.fetch_add(1, Ordering::Relaxed);
196
+ self.clients.write().await.insert(id, tx);
197
+ id
198
+ }
199
+
200
+ async fn remove_client(&self, client_id: u64) {
201
+ self.clients.write().await.remove(&client_id);
202
+ }
203
+
204
+ async fn send_json(&self, client_id: u64, value: Value) {
205
+ let text = match serde_json::to_string(&value) {
206
+ Ok(v) => v,
207
+ Err(error) => {
208
+ eprintln!("failed to serialize websocket payload: {error}");
209
+ return;
210
+ }
211
+ };
212
+
213
+ let tx = {
214
+ let clients = self.clients.read().await;
215
+ clients.get(&client_id).cloned()
216
+ };
217
+ let Some(tx) = tx else {
218
+ return;
219
+ };
220
+
221
+ let message = Message::Text(text.into());
222
+ let should_remove = match tx.try_send(message) {
223
+ Ok(()) => false,
224
+ Err(mpsc::error::TrySendError::Closed(_)) => true,
225
+ Err(mpsc::error::TrySendError::Full(message)) => {
226
+ match timeout(Duration::from_millis(250), tx.send(message)).await {
227
+ Ok(Ok(())) => false,
228
+ Ok(Err(_)) | Err(_) => true,
229
+ }
230
+ }
231
+ };
232
+
233
+ if should_remove {
234
+ self.remove_client(client_id).await;
235
+ }
236
+ }
237
+
238
+ async fn broadcast_json(&self, value: Value) {
239
+ let text = match serde_json::to_string(&value) {
240
+ Ok(v) => v,
241
+ Err(error) => {
242
+ eprintln!("failed to serialize broadcast payload: {error}");
243
+ return;
244
+ }
245
+ };
246
+
247
+ let mut stale_clients = Vec::new();
248
+ {
249
+ let clients = self.clients.read().await;
250
+ for (client_id, tx) in clients.iter() {
251
+ match tx.try_send(Message::Text(text.clone().into())) {
252
+ Ok(()) => {}
253
+ Err(mpsc::error::TrySendError::Closed(_)) => {
254
+ stale_clients.push(*client_id);
255
+ }
256
+ Err(mpsc::error::TrySendError::Full(_)) => {
257
+ // Keep the client and rely on replay to catch up dropped notifications.
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ if !stale_clients.is_empty() {
264
+ let mut clients = self.clients.write().await;
265
+ for client_id in stale_clients {
266
+ clients.remove(&client_id);
267
+ }
268
+ }
269
+ }
270
+
271
+ async fn broadcast_notification(&self, method: &str, params: Value) {
272
+ let event_id = self.next_event_id.fetch_add(1, Ordering::Relaxed);
273
+ let payload = json!({
274
+ "method": method,
275
+ "eventId": event_id,
276
+ "params": params
277
+ });
278
+
279
+ self.push_replay(event_id, payload.clone()).await;
280
+ self.broadcast_json(payload).await;
281
+ }
282
+
283
+ async fn push_replay(&self, event_id: u64, payload: Value) {
284
+ if self.replay_capacity == 0 {
285
+ return;
286
+ }
287
+
288
+ let mut replay = self.notification_replay.write().await;
289
+ replay.push_back(ReplayableNotification { event_id, payload });
290
+ while replay.len() > self.replay_capacity {
291
+ replay.pop_front();
292
+ }
293
+ }
294
+
295
+ async fn replay_since(&self, after_event_id: Option<u64>, limit: usize) -> (Vec<Value>, bool) {
296
+ let after = after_event_id.unwrap_or(0);
297
+ let replay = self.notification_replay.read().await;
298
+ let mut events = Vec::new();
299
+ let mut has_more = false;
300
+
301
+ for entry in replay.iter() {
302
+ if entry.event_id <= after {
303
+ continue;
304
+ }
305
+
306
+ if events.len() >= limit {
307
+ has_more = true;
308
+ break;
309
+ }
310
+
311
+ events.push(entry.payload.clone());
312
+ }
313
+
314
+ (events, has_more)
315
+ }
316
+
317
+ async fn earliest_event_id(&self) -> Option<u64> {
318
+ self.notification_replay
319
+ .read()
320
+ .await
321
+ .front()
322
+ .map(|entry| entry.event_id)
323
+ }
324
+
325
+ fn latest_event_id(&self) -> u64 {
326
+ self.next_event_id.load(Ordering::Relaxed).saturating_sub(1)
327
+ }
328
+ }
329
+
330
+ struct AppServerBridge {
331
+ child: Mutex<Child>,
332
+ writer: Mutex<ChildStdin>,
333
+ pending_requests: Mutex<HashMap<u64, PendingRequest>>,
334
+ internal_waiters: Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>,
335
+ pending_approvals: Mutex<HashMap<String, PendingApprovalEntry>>,
336
+ pending_user_inputs: Mutex<HashMap<String, PendingUserInputEntry>>,
337
+ next_request_id: AtomicU64,
338
+ approval_counter: AtomicU64,
339
+ user_input_counter: AtomicU64,
340
+ hub: Arc<ClientHub>,
341
+ }
342
+
343
+ struct PendingRequest {
344
+ client_id: u64,
345
+ client_request_id: Value,
346
+ }
347
+
348
+ #[derive(Clone)]
349
+ struct PendingApprovalEntry {
350
+ app_server_request_id: Value,
351
+ approval: PendingApproval,
352
+ }
353
+
354
+ #[derive(Clone)]
355
+ struct PendingUserInputEntry {
356
+ app_server_request_id: Value,
357
+ request: PendingUserInputRequest,
358
+ }
359
+
360
+ impl AppServerBridge {
361
+ async fn start(cli_bin: &str, hub: Arc<ClientHub>) -> Result<Arc<Self>, String> {
362
+ let mut child = Command::new(cli_bin)
363
+ .arg("app-server")
364
+ .arg("--listen")
365
+ .arg("stdio://")
366
+ .stdin(Stdio::piped())
367
+ .stdout(Stdio::piped())
368
+ .stderr(Stdio::piped())
369
+ .spawn()
370
+ .map_err(|error| format!("failed to start app-server: {error}"))?;
371
+
372
+ let stdin = child
373
+ .stdin
374
+ .take()
375
+ .ok_or_else(|| "app-server stdin unavailable".to_string())?;
376
+ let stdout = child
377
+ .stdout
378
+ .take()
379
+ .ok_or_else(|| "app-server stdout unavailable".to_string())?;
380
+ let stderr = child
381
+ .stderr
382
+ .take()
383
+ .ok_or_else(|| "app-server stderr unavailable".to_string())?;
384
+
385
+ let bridge = Arc::new(Self {
386
+ child: Mutex::new(child),
387
+ writer: Mutex::new(stdin),
388
+ pending_requests: Mutex::new(HashMap::new()),
389
+ internal_waiters: Mutex::new(HashMap::new()),
390
+ pending_approvals: Mutex::new(HashMap::new()),
391
+ pending_user_inputs: Mutex::new(HashMap::new()),
392
+ next_request_id: AtomicU64::new(1),
393
+ approval_counter: AtomicU64::new(1),
394
+ user_input_counter: AtomicU64::new(1),
395
+ hub,
396
+ });
397
+
398
+ bridge.spawn_stdout_loop(stdout);
399
+ bridge.spawn_stderr_loop(stderr);
400
+ bridge.spawn_wait_loop();
401
+
402
+ bridge.initialize().await?;
403
+
404
+ Ok(bridge)
405
+ }
406
+
407
+ async fn initialize(&self) -> Result<(), String> {
408
+ let init_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
409
+ let (tx, rx) = oneshot::channel::<Result<Value, String>>();
410
+ self.internal_waiters.lock().await.insert(init_id, tx);
411
+
412
+ let initialize_request = json!({
413
+ "id": init_id,
414
+ "method": "initialize",
415
+ "params": {
416
+ "clientInfo": {
417
+ "name": "clawdex-mobile-rust-bridge",
418
+ "title": "Clawdex Mobile Rust Bridge",
419
+ "version": "0.1.0"
420
+ },
421
+ "capabilities": {
422
+ "experimentalApi": true
423
+ }
424
+ }
425
+ });
426
+
427
+ self.write_json(initialize_request)
428
+ .await
429
+ .map_err(|error| format!("initialize write failed: {error}"))?;
430
+
431
+ let init_result = timeout(Duration::from_secs(15), rx)
432
+ .await
433
+ .map_err(|_| "app-server initialize timed out".to_string())?;
434
+
435
+ match init_result {
436
+ Ok(Ok(_)) => {}
437
+ Ok(Err(message)) => return Err(format!("app-server initialize failed: {message}")),
438
+ Err(_) => return Err("app-server initialize waiter dropped".to_string()),
439
+ }
440
+
441
+ self.write_json(json!({
442
+ "method": "initialized",
443
+ "params": {}
444
+ }))
445
+ .await
446
+ .map_err(|error| format!("initialized write failed: {error}"))?;
447
+
448
+ Ok(())
449
+ }
450
+
451
+ fn spawn_stdout_loop(self: &Arc<Self>, stdout: ChildStdout) {
452
+ let this = Arc::clone(self);
453
+ tokio::spawn(async move {
454
+ let mut lines = BufReader::new(stdout).lines();
455
+
456
+ loop {
457
+ match lines.next_line().await {
458
+ Ok(Some(line)) => {
459
+ let trimmed = line.trim();
460
+ if trimmed.is_empty() {
461
+ continue;
462
+ }
463
+
464
+ match serde_json::from_str::<Value>(trimmed) {
465
+ Ok(value) => this.handle_incoming(value).await,
466
+ Err(error) => {
467
+ eprintln!("invalid app-server json: {error} | line={trimmed}");
468
+ }
469
+ }
470
+ }
471
+ Ok(None) => break,
472
+ Err(error) => {
473
+ eprintln!("app-server stdout read error: {error}");
474
+ break;
475
+ }
476
+ }
477
+ }
478
+ });
479
+ }
480
+
481
+ fn spawn_stderr_loop(self: &Arc<Self>, stderr: tokio::process::ChildStderr) {
482
+ tokio::spawn(async move {
483
+ let mut lines = BufReader::new(stderr).lines();
484
+ loop {
485
+ match lines.next_line().await {
486
+ Ok(Some(line)) => eprintln!("[app-server] {line}"),
487
+ Ok(None) => break,
488
+ Err(error) => {
489
+ eprintln!("app-server stderr read error: {error}");
490
+ break;
491
+ }
492
+ }
493
+ }
494
+ });
495
+ }
496
+
497
+ fn spawn_wait_loop(self: &Arc<Self>) {
498
+ let this = Arc::clone(self);
499
+ tokio::spawn(async move {
500
+ let status_result = {
501
+ let mut child = this.child.lock().await;
502
+ child.wait().await
503
+ };
504
+
505
+ match status_result {
506
+ Ok(status) => {
507
+ eprintln!("app-server exited with status: {status}");
508
+ }
509
+ Err(error) => {
510
+ eprintln!("failed waiting for app-server exit: {error}");
511
+ }
512
+ }
513
+
514
+ this.fail_all_pending("app-server closed").await;
515
+ this.pending_approvals.lock().await.clear();
516
+ this.pending_user_inputs.lock().await.clear();
517
+ });
518
+ }
519
+
520
+ async fn fail_all_pending(&self, message: &str) {
521
+ let pending_entries = {
522
+ let mut pending = self.pending_requests.lock().await;
523
+ pending.drain().map(|(_, entry)| entry).collect::<Vec<_>>()
524
+ };
525
+
526
+ for pending in pending_entries {
527
+ self.hub
528
+ .send_json(
529
+ pending.client_id,
530
+ json!({
531
+ "id": pending.client_request_id,
532
+ "error": {
533
+ "code": -32000,
534
+ "message": message
535
+ }
536
+ }),
537
+ )
538
+ .await;
539
+ }
540
+ }
541
+
542
+ async fn forward_request(
543
+ &self,
544
+ client_id: u64,
545
+ client_request_id: Value,
546
+ method: &str,
547
+ params: Option<Value>,
548
+ ) -> Result<(), String> {
549
+ let internal_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
550
+
551
+ {
552
+ let mut pending = self.pending_requests.lock().await;
553
+ pending.insert(
554
+ internal_id,
555
+ PendingRequest {
556
+ client_id,
557
+ client_request_id,
558
+ },
559
+ );
560
+ }
561
+
562
+ let mut payload = json!({
563
+ "id": internal_id,
564
+ "method": method,
565
+ });
566
+ if let Some(params) = params {
567
+ payload["params"] = params;
568
+ }
569
+
570
+ if let Err(error) = self.write_json(payload).await {
571
+ self.pending_requests.lock().await.remove(&internal_id);
572
+ return Err(format!("failed forwarding request to app-server: {error}"));
573
+ }
574
+
575
+ Ok(())
576
+ }
577
+
578
+ async fn list_pending_approvals(&self) -> Vec<PendingApproval> {
579
+ let mut approvals = self
580
+ .pending_approvals
581
+ .lock()
582
+ .await
583
+ .values()
584
+ .map(|entry| entry.approval.clone())
585
+ .collect::<Vec<_>>();
586
+
587
+ approvals.sort_by(|a, b| b.requested_at.cmp(&a.requested_at));
588
+ approvals
589
+ }
590
+
591
+ async fn resolve_approval(
592
+ &self,
593
+ approval_id: &str,
594
+ decision: &Value,
595
+ ) -> Result<Option<PendingApproval>, String> {
596
+ let pending = self.pending_approvals.lock().await.remove(approval_id);
597
+ let Some(pending) = pending else {
598
+ return Ok(None);
599
+ };
600
+
601
+ let response = json!({
602
+ "id": pending.app_server_request_id,
603
+ "result": {
604
+ "decision": decision
605
+ }
606
+ });
607
+
608
+ if let Err(error) = self.write_json(response).await {
609
+ self.pending_approvals
610
+ .lock()
611
+ .await
612
+ .insert(approval_id.to_string(), pending.clone());
613
+ return Err(format!("failed to send approval response: {error}"));
614
+ }
615
+
616
+ self.hub
617
+ .broadcast_notification(
618
+ "bridge/approval.resolved",
619
+ json!({
620
+ "id": pending.approval.id,
621
+ "threadId": pending.approval.thread_id,
622
+ "decision": decision,
623
+ "resolvedAt": now_iso(),
624
+ }),
625
+ )
626
+ .await;
627
+
628
+ Ok(Some(pending.approval))
629
+ }
630
+
631
+ async fn resolve_user_input(
632
+ &self,
633
+ request_id: &str,
634
+ answers: &HashMap<String, UserInputAnswerPayload>,
635
+ ) -> Result<Option<PendingUserInputRequest>, String> {
636
+ let pending = self.pending_user_inputs.lock().await.remove(request_id);
637
+ let Some(pending) = pending else {
638
+ return Ok(None);
639
+ };
640
+
641
+ let response = json!({
642
+ "id": pending.app_server_request_id,
643
+ "result": {
644
+ "answers": answers
645
+ }
646
+ });
647
+
648
+ if let Err(error) = self.write_json(response).await {
649
+ self.pending_user_inputs
650
+ .lock()
651
+ .await
652
+ .insert(request_id.to_string(), pending.clone());
653
+ return Err(format!("failed to send requestUserInput response: {error}"));
654
+ }
655
+
656
+ self.hub
657
+ .broadcast_notification(
658
+ "bridge/userInput.resolved",
659
+ json!({
660
+ "id": pending.request.id,
661
+ "threadId": pending.request.thread_id,
662
+ "turnId": pending.request.turn_id,
663
+ "resolvedAt": now_iso(),
664
+ }),
665
+ )
666
+ .await;
667
+
668
+ Ok(Some(pending.request))
669
+ }
670
+
671
+ async fn handle_incoming(&self, value: Value) {
672
+ let Some(object) = value.as_object() else {
673
+ return;
674
+ };
675
+
676
+ let method = object
677
+ .get("method")
678
+ .and_then(Value::as_str)
679
+ .map(str::to_string);
680
+ let id = object.get("id").cloned();
681
+
682
+ match (method, id) {
683
+ (Some(method), Some(id)) => {
684
+ self.handle_server_request(&method, id, object.get("params").cloned())
685
+ .await;
686
+ }
687
+ (Some(method), None) => {
688
+ self.handle_notification(&method, object.get("params").cloned())
689
+ .await;
690
+ }
691
+ (None, Some(_)) => {
692
+ self.handle_response(value).await;
693
+ }
694
+ (None, None) => {}
695
+ }
696
+ }
697
+
698
+ async fn handle_server_request(&self, method: &str, id: Value, params: Option<Value>) {
699
+ if method == APPROVAL_COMMAND_METHOD || method == APPROVAL_FILE_METHOD {
700
+ let params_obj = params.as_ref().and_then(Value::as_object);
701
+ let approval_id = format!(
702
+ "{}-{}",
703
+ Utc::now().timestamp_millis(),
704
+ self.approval_counter.fetch_add(1, Ordering::Relaxed)
705
+ );
706
+
707
+ let approval = PendingApproval {
708
+ id: approval_id.clone(),
709
+ kind: if method == APPROVAL_COMMAND_METHOD {
710
+ "commandExecution".to_string()
711
+ } else {
712
+ "fileChange".to_string()
713
+ },
714
+ thread_id: read_string(params_obj.and_then(|p| p.get("threadId")))
715
+ .unwrap_or_else(|| "unknown-thread".to_string()),
716
+ turn_id: read_string(params_obj.and_then(|p| p.get("turnId")))
717
+ .unwrap_or_else(|| "unknown-turn".to_string()),
718
+ item_id: read_string(params_obj.and_then(|p| p.get("itemId")))
719
+ .unwrap_or_else(|| "unknown-item".to_string()),
720
+ requested_at: now_iso(),
721
+ reason: read_string(params_obj.and_then(|p| p.get("reason"))),
722
+ command: read_string(params_obj.and_then(|p| p.get("command"))),
723
+ cwd: read_string(params_obj.and_then(|p| p.get("cwd"))),
724
+ grant_root: read_string(params_obj.and_then(|p| p.get("grantRoot"))),
725
+ proposed_execpolicy_amendment: parse_execpolicy_amendment(
726
+ params_obj.and_then(|p| p.get("proposedExecpolicyAmendment")),
727
+ ),
728
+ };
729
+
730
+ self.pending_approvals.lock().await.insert(
731
+ approval_id,
732
+ PendingApprovalEntry {
733
+ app_server_request_id: id,
734
+ approval: approval.clone(),
735
+ },
736
+ );
737
+
738
+ self.hub
739
+ .broadcast_notification(
740
+ "bridge/approval.requested",
741
+ serde_json::to_value(approval).unwrap_or(Value::Null),
742
+ )
743
+ .await;
744
+ return;
745
+ }
746
+
747
+ if method == REQUEST_USER_INPUT_METHOD || method == REQUEST_USER_INPUT_METHOD_ALT {
748
+ let params_obj = params.as_ref().and_then(Value::as_object);
749
+ let request_id = format!(
750
+ "request-user-input-{}-{}",
751
+ Utc::now().timestamp_millis(),
752
+ self.user_input_counter.fetch_add(1, Ordering::Relaxed)
753
+ );
754
+
755
+ let request = PendingUserInputRequest {
756
+ id: request_id.clone(),
757
+ thread_id: read_string(params_obj.and_then(|p| p.get("threadId")))
758
+ .unwrap_or_else(|| "unknown-thread".to_string()),
759
+ turn_id: read_string(params_obj.and_then(|p| p.get("turnId")))
760
+ .unwrap_or_else(|| "unknown-turn".to_string()),
761
+ item_id: read_string(params_obj.and_then(|p| p.get("itemId")))
762
+ .unwrap_or_else(|| "unknown-item".to_string()),
763
+ requested_at: now_iso(),
764
+ questions: parse_user_input_questions(params_obj.and_then(|p| p.get("questions"))),
765
+ };
766
+
767
+ self.pending_user_inputs.lock().await.insert(
768
+ request_id,
769
+ PendingUserInputEntry {
770
+ app_server_request_id: id,
771
+ request: request.clone(),
772
+ },
773
+ );
774
+
775
+ self.hub
776
+ .broadcast_notification(
777
+ "bridge/userInput.requested",
778
+ serde_json::to_value(request).unwrap_or(Value::Null),
779
+ )
780
+ .await;
781
+ return;
782
+ }
783
+
784
+ let _ = self
785
+ .write_json(json!({
786
+ "id": id,
787
+ "error": {
788
+ "code": -32601,
789
+ "message": format!("Unsupported server request method: {method}")
790
+ }
791
+ }))
792
+ .await;
793
+ }
794
+
795
+ async fn handle_notification(&self, method: &str, params: Option<Value>) {
796
+ self.hub
797
+ .broadcast_notification(method, params.unwrap_or(Value::Null))
798
+ .await;
799
+ }
800
+
801
+ async fn handle_response(&self, response: Value) {
802
+ let Some(object) = response.as_object() else {
803
+ return;
804
+ };
805
+
806
+ let Some(internal_id) = parse_internal_id(object.get("id")) else {
807
+ return;
808
+ };
809
+
810
+ let pending = self.pending_requests.lock().await.remove(&internal_id);
811
+ if pending.is_none() {
812
+ let waiter = self.internal_waiters.lock().await.remove(&internal_id);
813
+ if let Some(waiter) = waiter {
814
+ if let Some(error) = object.get("error") {
815
+ let message = error
816
+ .as_object()
817
+ .and_then(|entry| entry.get("message"))
818
+ .and_then(Value::as_str)
819
+ .unwrap_or("unknown initialize error")
820
+ .to_string();
821
+ let _ = waiter.send(Err(message));
822
+ } else {
823
+ let _ = waiter.send(Ok(object.get("result").cloned().unwrap_or(Value::Null)));
824
+ }
825
+ return;
826
+ }
827
+ }
828
+ let Some(pending) = pending else {
829
+ return;
830
+ };
831
+
832
+ let client_payload = if let Some(error) = object.get("error") {
833
+ json!({
834
+ "id": pending.client_request_id,
835
+ "error": error,
836
+ })
837
+ } else {
838
+ json!({
839
+ "id": pending.client_request_id,
840
+ "result": object.get("result").cloned().unwrap_or(Value::Null),
841
+ })
842
+ };
843
+
844
+ self.hub.send_json(pending.client_id, client_payload).await;
845
+ }
846
+
847
+ async fn write_json(&self, payload: Value) -> Result<(), std::io::Error> {
848
+ let line = serde_json::to_string(&payload).map_err(std::io::Error::other)?;
849
+ let mut writer = self.writer.lock().await;
850
+ writer.write_all(line.as_bytes()).await?;
851
+ writer.write_all(b"\n").await?;
852
+ writer.flush().await
853
+ }
854
+ }
855
+
856
+ #[derive(Debug)]
857
+ struct BridgeError {
858
+ code: i64,
859
+ message: String,
860
+ data: Option<Value>,
861
+ }
862
+
863
+ impl BridgeError {
864
+ fn method_not_found(message: &str) -> Self {
865
+ Self {
866
+ code: -32601,
867
+ message: message.to_string(),
868
+ data: None,
869
+ }
870
+ }
871
+
872
+ fn invalid_params(message: &str) -> Self {
873
+ Self {
874
+ code: -32602,
875
+ message: message.to_string(),
876
+ data: None,
877
+ }
878
+ }
879
+
880
+ fn server(message: &str) -> Self {
881
+ Self {
882
+ code: -32000,
883
+ message: message.to_string(),
884
+ data: None,
885
+ }
886
+ }
887
+
888
+ fn forbidden(error: &str, message: &str) -> Self {
889
+ Self {
890
+ code: -32003,
891
+ message: message.to_string(),
892
+ data: Some(json!({ "error": error })),
893
+ }
894
+ }
895
+ }
896
+
897
+ #[derive(Debug, Clone, Serialize, Deserialize)]
898
+ #[serde(rename_all = "camelCase")]
899
+ struct TerminalExecRequest {
900
+ command: String,
901
+ cwd: Option<String>,
902
+ timeout_ms: Option<u64>,
903
+ }
904
+
905
+ #[derive(Debug, Clone, Serialize, Deserialize)]
906
+ #[serde(rename_all = "camelCase")]
907
+ struct TerminalExecResponse {
908
+ command: String,
909
+ cwd: String,
910
+ code: Option<i32>,
911
+ stdout: String,
912
+ stderr: String,
913
+ timed_out: bool,
914
+ duration_ms: u64,
915
+ }
916
+
917
+ #[derive(Debug, Clone, Serialize, Deserialize)]
918
+ struct GitStatusResponse {
919
+ branch: String,
920
+ clean: bool,
921
+ raw: String,
922
+ cwd: String,
923
+ }
924
+
925
+ #[derive(Debug, Clone, Serialize, Deserialize)]
926
+ struct GitDiffResponse {
927
+ diff: String,
928
+ cwd: String,
929
+ }
930
+
931
+ #[derive(Debug, Clone, Serialize, Deserialize)]
932
+ struct GitCommitResponse {
933
+ code: Option<i32>,
934
+ stdout: String,
935
+ stderr: String,
936
+ committed: bool,
937
+ cwd: String,
938
+ }
939
+
940
+ #[derive(Debug, Clone, Serialize, Deserialize)]
941
+ struct GitPushResponse {
942
+ code: Option<i32>,
943
+ stdout: String,
944
+ stderr: String,
945
+ pushed: bool,
946
+ cwd: String,
947
+ }
948
+
949
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
950
+ #[serde(rename_all = "camelCase")]
951
+ struct GitQueryRequest {
952
+ cwd: Option<String>,
953
+ }
954
+
955
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
956
+ #[serde(rename_all = "camelCase")]
957
+ struct EventReplayRequest {
958
+ after_event_id: Option<u64>,
959
+ limit: Option<usize>,
960
+ }
961
+
962
+ #[derive(Debug, Clone, Serialize, Deserialize)]
963
+ #[serde(rename_all = "camelCase")]
964
+ struct GitCommitRequest {
965
+ message: String,
966
+ cwd: Option<String>,
967
+ }
968
+
969
+ #[derive(Debug, Clone, Serialize, Deserialize)]
970
+ #[serde(rename_all = "camelCase")]
971
+ struct AttachmentUploadRequest {
972
+ data_base64: String,
973
+ file_name: Option<String>,
974
+ mime_type: Option<String>,
975
+ thread_id: Option<String>,
976
+ kind: Option<String>,
977
+ }
978
+
979
+ #[derive(Debug, Clone, Serialize, Deserialize)]
980
+ #[serde(rename_all = "camelCase")]
981
+ struct AttachmentUploadResponse {
982
+ path: String,
983
+ file_name: String,
984
+ mime_type: Option<String>,
985
+ size_bytes: usize,
986
+ kind: String,
987
+ }
988
+
989
+ #[derive(Debug, Clone, Serialize, Deserialize)]
990
+ #[serde(rename_all = "camelCase")]
991
+ struct PendingApproval {
992
+ id: String,
993
+ kind: String,
994
+ thread_id: String,
995
+ turn_id: String,
996
+ item_id: String,
997
+ requested_at: String,
998
+ reason: Option<String>,
999
+ command: Option<String>,
1000
+ cwd: Option<String>,
1001
+ grant_root: Option<String>,
1002
+ proposed_execpolicy_amendment: Option<Vec<String>>,
1003
+ }
1004
+
1005
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1006
+ #[serde(rename_all = "camelCase")]
1007
+ struct ResolveApprovalRequest {
1008
+ id: String,
1009
+ decision: Value,
1010
+ }
1011
+
1012
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1013
+ #[serde(rename_all = "camelCase")]
1014
+ struct UserInputAnswerPayload {
1015
+ answers: Vec<String>,
1016
+ }
1017
+
1018
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1019
+ #[serde(rename_all = "camelCase")]
1020
+ struct ResolveUserInputRequest {
1021
+ id: String,
1022
+ answers: HashMap<String, UserInputAnswerPayload>,
1023
+ }
1024
+
1025
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1026
+ #[serde(rename_all = "camelCase")]
1027
+ struct PendingUserInputRequest {
1028
+ id: String,
1029
+ thread_id: String,
1030
+ turn_id: String,
1031
+ item_id: String,
1032
+ requested_at: String,
1033
+ questions: Vec<PendingUserInputQuestion>,
1034
+ }
1035
+
1036
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1037
+ #[serde(rename_all = "camelCase")]
1038
+ struct PendingUserInputQuestion {
1039
+ id: String,
1040
+ header: String,
1041
+ question: String,
1042
+ is_other: bool,
1043
+ is_secret: bool,
1044
+ options: Option<Vec<PendingUserInputQuestionOption>>,
1045
+ }
1046
+
1047
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1048
+ #[serde(rename_all = "camelCase")]
1049
+ struct PendingUserInputQuestionOption {
1050
+ label: String,
1051
+ description: String,
1052
+ }
1053
+
1054
+ #[derive(Debug, Deserialize)]
1055
+ struct RpcQuery {
1056
+ token: Option<String>,
1057
+ }
1058
+
1059
+ #[tokio::main]
1060
+ async fn main() {
1061
+ let config = match BridgeConfig::from_env() {
1062
+ Ok(config) => Arc::new(config),
1063
+ Err(error) => {
1064
+ eprintln!("{error}");
1065
+ std::process::exit(1);
1066
+ }
1067
+ };
1068
+
1069
+ if !config.auth_enabled && config.allow_insecure_no_auth {
1070
+ eprintln!(
1071
+ "bridge auth is disabled by BRIDGE_ALLOW_INSECURE_NO_AUTH=true (local development only)"
1072
+ );
1073
+ }
1074
+ if config.allow_query_token_auth {
1075
+ eprintln!(
1076
+ "query-token auth is enabled (BRIDGE_ALLOW_QUERY_TOKEN_AUTH=true); prefer Authorization headers instead"
1077
+ );
1078
+ }
1079
+
1080
+ let hub = Arc::new(ClientHub::new());
1081
+ let app_server = match AppServerBridge::start(&config.cli_bin, hub.clone()).await {
1082
+ Ok(client) => client,
1083
+ Err(error) => {
1084
+ eprintln!("{error}");
1085
+ std::process::exit(1);
1086
+ }
1087
+ };
1088
+
1089
+ let terminal = Arc::new(TerminalService::new(
1090
+ config.workdir.clone(),
1091
+ config.terminal_allowed_commands.clone(),
1092
+ config.disable_terminal_exec,
1093
+ config.allow_outside_root_cwd,
1094
+ ));
1095
+ let git = Arc::new(GitService::new(
1096
+ terminal.clone(),
1097
+ config.workdir.clone(),
1098
+ config.allow_outside_root_cwd,
1099
+ ));
1100
+
1101
+ let state = Arc::new(AppState {
1102
+ config: config.clone(),
1103
+ started_at: Instant::now(),
1104
+ hub,
1105
+ app_server,
1106
+ terminal,
1107
+ git,
1108
+ });
1109
+
1110
+ let app = Router::new()
1111
+ .route("/rpc", get(ws_handler))
1112
+ .route("/health", get(health_handler))
1113
+ .with_state(state);
1114
+
1115
+ let bind_addr = format!("{}:{}", config.host, config.port);
1116
+ let listener = match tokio::net::TcpListener::bind(&bind_addr).await {
1117
+ Ok(listener) => listener,
1118
+ Err(error) => {
1119
+ eprintln!("failed to bind {bind_addr}: {error}");
1120
+ std::process::exit(1);
1121
+ }
1122
+ };
1123
+
1124
+ println!("rust-bridge listening on {bind_addr}");
1125
+
1126
+ if let Err(error) = axum::serve(listener, app).await {
1127
+ eprintln!("server error: {error}");
1128
+ std::process::exit(1);
1129
+ }
1130
+ }
1131
+
1132
+ async fn health_handler(State(state): State<Arc<AppState>>) -> Json<Value> {
1133
+ Json(json!({
1134
+ "status": "ok",
1135
+ "at": now_iso(),
1136
+ "uptimeSec": state.started_at.elapsed().as_secs(),
1137
+ }))
1138
+ }
1139
+
1140
+ async fn ws_handler(
1141
+ ws: WebSocketUpgrade,
1142
+ State(state): State<Arc<AppState>>,
1143
+ headers: HeaderMap,
1144
+ Query(query): Query<RpcQuery>,
1145
+ ) -> Response {
1146
+ if !state.config.is_authorized(&headers, query.token.as_deref()) {
1147
+ return (
1148
+ StatusCode::UNAUTHORIZED,
1149
+ Json(json!({
1150
+ "error": "unauthorized",
1151
+ "message": "Missing or invalid bridge token"
1152
+ })),
1153
+ )
1154
+ .into_response();
1155
+ }
1156
+
1157
+ ws.on_upgrade(move |socket| handle_socket(socket, state))
1158
+ .into_response()
1159
+ }
1160
+
1161
+ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
1162
+ let (mut socket_tx, mut socket_rx) = socket.split();
1163
+ let (tx, mut rx) = mpsc::channel::<Message>(WS_CLIENT_QUEUE_CAPACITY);
1164
+ let client_id = state.hub.add_client(tx).await;
1165
+
1166
+ let mut writer_task = tokio::spawn(async move {
1167
+ while let Some(message) = rx.recv().await {
1168
+ if socket_tx.send(message).await.is_err() {
1169
+ break;
1170
+ }
1171
+ }
1172
+ });
1173
+
1174
+ state
1175
+ .hub
1176
+ .send_json(
1177
+ client_id,
1178
+ json!({
1179
+ "method": "bridge/connection/state",
1180
+ "params": {
1181
+ "status": "connected",
1182
+ "at": now_iso(),
1183
+ }
1184
+ }),
1185
+ )
1186
+ .await;
1187
+
1188
+ loop {
1189
+ tokio::select! {
1190
+ writer_result = &mut writer_task => {
1191
+ if let Err(error) = writer_result {
1192
+ eprintln!("websocket writer task error: {error}");
1193
+ }
1194
+ break;
1195
+ }
1196
+ maybe_message = socket_rx.next() => {
1197
+ let Some(message) = maybe_message else {
1198
+ break;
1199
+ };
1200
+
1201
+ match message {
1202
+ Ok(Message::Text(text)) => {
1203
+ handle_client_message(client_id, text.to_string(), &state).await;
1204
+ }
1205
+ Ok(Message::Close(_)) => break,
1206
+ Ok(Message::Binary(_)) => {
1207
+ state
1208
+ .hub
1209
+ .send_json(
1210
+ client_id,
1211
+ json!({
1212
+ "id": Value::Null,
1213
+ "error": {
1214
+ "code": -32600,
1215
+ "message": "Binary websocket messages are not supported"
1216
+ }
1217
+ }),
1218
+ )
1219
+ .await;
1220
+ }
1221
+ Ok(Message::Ping(payload)) => {
1222
+ state
1223
+ .hub
1224
+ .send_json(
1225
+ client_id,
1226
+ json!({
1227
+ "method": "bridge/ping",
1228
+ "params": {
1229
+ "size": payload.len()
1230
+ }
1231
+ }),
1232
+ )
1233
+ .await;
1234
+ }
1235
+ Ok(Message::Pong(_)) => {}
1236
+ Err(error) => {
1237
+ eprintln!("websocket error: {error}");
1238
+ break;
1239
+ }
1240
+ }
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ state.hub.remove_client(client_id).await;
1246
+ if !writer_task.is_finished() {
1247
+ writer_task.abort();
1248
+ }
1249
+ }
1250
+
1251
+ async fn handle_client_message(client_id: u64, text: String, state: &Arc<AppState>) {
1252
+ let parsed = match serde_json::from_str::<Value>(&text) {
1253
+ Ok(value) => value,
1254
+ Err(error) => {
1255
+ send_rpc_error(
1256
+ state,
1257
+ client_id,
1258
+ Value::Null,
1259
+ -32700,
1260
+ &format!("Parse error: {error}"),
1261
+ None,
1262
+ )
1263
+ .await;
1264
+ return;
1265
+ }
1266
+ };
1267
+
1268
+ let Some(object) = parsed.as_object() else {
1269
+ send_rpc_error(
1270
+ state,
1271
+ client_id,
1272
+ Value::Null,
1273
+ -32600,
1274
+ "Invalid request payload",
1275
+ None,
1276
+ )
1277
+ .await;
1278
+ return;
1279
+ };
1280
+
1281
+ let Some(method) = object.get("method").and_then(Value::as_str) else {
1282
+ send_rpc_error(
1283
+ state,
1284
+ client_id,
1285
+ object.get("id").cloned().unwrap_or(Value::Null),
1286
+ -32600,
1287
+ "Missing method",
1288
+ None,
1289
+ )
1290
+ .await;
1291
+ return;
1292
+ };
1293
+
1294
+ let Some(id) = object.get("id").cloned() else {
1295
+ // Ignore client-side notifications for now.
1296
+ return;
1297
+ };
1298
+
1299
+ let params = object.get("params").cloned();
1300
+
1301
+ if method.starts_with("bridge/") {
1302
+ match handle_bridge_method(method, params, state).await {
1303
+ Ok(result) => {
1304
+ state
1305
+ .hub
1306
+ .send_json(client_id, json!({ "id": id, "result": result }))
1307
+ .await;
1308
+ }
1309
+ Err(error) => {
1310
+ send_rpc_error(state, client_id, id, error.code, &error.message, error.data).await;
1311
+ }
1312
+ }
1313
+ return;
1314
+ }
1315
+
1316
+ if !is_forwarded_method(method) {
1317
+ send_rpc_error(
1318
+ state,
1319
+ client_id,
1320
+ id,
1321
+ -32601,
1322
+ &format!("Method not allowed: {method}"),
1323
+ None,
1324
+ )
1325
+ .await;
1326
+ return;
1327
+ }
1328
+
1329
+ if let Err(error) = state
1330
+ .app_server
1331
+ .forward_request(client_id, id.clone(), method, params)
1332
+ .await
1333
+ {
1334
+ send_rpc_error(state, client_id, id, -32000, &error, None).await;
1335
+ }
1336
+ }
1337
+
1338
+ async fn handle_bridge_method(
1339
+ method: &str,
1340
+ params: Option<Value>,
1341
+ state: &Arc<AppState>,
1342
+ ) -> Result<Value, BridgeError> {
1343
+ match method {
1344
+ "bridge/health/read" => Ok(json!({
1345
+ "status": "ok",
1346
+ "at": now_iso(),
1347
+ "uptimeSec": state.started_at.elapsed().as_secs(),
1348
+ })),
1349
+ "bridge/events/replay" => {
1350
+ let request: EventReplayRequest =
1351
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1352
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1353
+
1354
+ let limit = request
1355
+ .limit
1356
+ .unwrap_or(200)
1357
+ .clamp(1, NOTIFICATION_REPLAY_MAX_LIMIT);
1358
+ let (events, has_more) = state.hub.replay_since(request.after_event_id, limit).await;
1359
+
1360
+ Ok(json!({
1361
+ "events": events,
1362
+ "hasMore": has_more,
1363
+ "earliestEventId": state.hub.earliest_event_id().await,
1364
+ "latestEventId": state.hub.latest_event_id(),
1365
+ }))
1366
+ }
1367
+ "bridge/terminal/exec" => {
1368
+ let request: TerminalExecRequest =
1369
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1370
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1371
+
1372
+ let result = state.terminal.execute_shell(request).await?;
1373
+ let result_value = serde_json::to_value(&result)
1374
+ .map_err(|error| BridgeError::server(&error.to_string()))?;
1375
+
1376
+ state
1377
+ .hub
1378
+ .broadcast_notification("bridge/terminal/completed", result_value.clone())
1379
+ .await;
1380
+
1381
+ Ok(result_value)
1382
+ }
1383
+ "bridge/attachments/upload" => {
1384
+ let request: AttachmentUploadRequest =
1385
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1386
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1387
+ let uploaded = save_uploaded_attachment(request, state).await?;
1388
+ serde_json::to_value(uploaded).map_err(|error| BridgeError::server(&error.to_string()))
1389
+ }
1390
+ "bridge/git/status" => {
1391
+ let request: GitQueryRequest =
1392
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1393
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1394
+ let status = state.git.get_status(request.cwd.as_deref()).await?;
1395
+ serde_json::to_value(status).map_err(|error| BridgeError::server(&error.to_string()))
1396
+ }
1397
+ "bridge/git/diff" => {
1398
+ let request: GitQueryRequest =
1399
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1400
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1401
+ let diff = state.git.get_diff(request.cwd.as_deref()).await?;
1402
+ serde_json::to_value(diff).map_err(|error| BridgeError::server(&error.to_string()))
1403
+ }
1404
+ "bridge/git/commit" => {
1405
+ let request: GitCommitRequest =
1406
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1407
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1408
+ let GitCommitRequest { message, cwd } = request;
1409
+
1410
+ if message.trim().is_empty() {
1411
+ return Err(BridgeError::invalid_params("message must not be empty"));
1412
+ }
1413
+
1414
+ let commit = state.git.commit(message, cwd.as_deref()).await?;
1415
+ let commit_value = serde_json::to_value(&commit)
1416
+ .map_err(|error| BridgeError::server(&error.to_string()))?;
1417
+
1418
+ if commit.committed {
1419
+ if let Ok(status) = state.git.get_status(cwd.as_deref()).await {
1420
+ let status_value = serde_json::to_value(status)
1421
+ .map_err(|error| BridgeError::server(&error.to_string()))?;
1422
+ state
1423
+ .hub
1424
+ .broadcast_notification("bridge/git/updated", status_value)
1425
+ .await;
1426
+ }
1427
+ }
1428
+
1429
+ Ok(commit_value)
1430
+ }
1431
+ "bridge/git/push" => {
1432
+ let request: GitQueryRequest =
1433
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1434
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1435
+
1436
+ let push = state.git.push(request.cwd.as_deref()).await?;
1437
+ let push_value = serde_json::to_value(&push)
1438
+ .map_err(|error| BridgeError::server(&error.to_string()))?;
1439
+
1440
+ if push.pushed {
1441
+ if let Ok(status) = state.git.get_status(request.cwd.as_deref()).await {
1442
+ let status_value = serde_json::to_value(status)
1443
+ .map_err(|error| BridgeError::server(&error.to_string()))?;
1444
+ state
1445
+ .hub
1446
+ .broadcast_notification("bridge/git/updated", status_value)
1447
+ .await;
1448
+ }
1449
+ }
1450
+
1451
+ Ok(push_value)
1452
+ }
1453
+ "bridge/approvals/list" => {
1454
+ let list = state.app_server.list_pending_approvals().await;
1455
+ serde_json::to_value(list).map_err(|error| BridgeError::server(&error.to_string()))
1456
+ }
1457
+ "bridge/approvals/resolve" => {
1458
+ let request: ResolveApprovalRequest =
1459
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1460
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1461
+
1462
+ if !is_valid_approval_decision(&request.decision) {
1463
+ return Err(BridgeError::invalid_params(
1464
+ "decision must be one of: accept, acceptForSession, decline, cancel, or acceptWithExecpolicyAmendment",
1465
+ ));
1466
+ }
1467
+
1468
+ let resolved = state
1469
+ .app_server
1470
+ .resolve_approval(&request.id, &request.decision)
1471
+ .await
1472
+ .map_err(|error| BridgeError::server(&error))?;
1473
+
1474
+ let Some(approval) = resolved else {
1475
+ return Err(BridgeError {
1476
+ code: -32004,
1477
+ message: "approval_not_found".to_string(),
1478
+ data: Some(json!({ "error": "approval_not_found" })),
1479
+ });
1480
+ };
1481
+
1482
+ Ok(json!({
1483
+ "ok": true,
1484
+ "approval": approval,
1485
+ "decision": request.decision,
1486
+ }))
1487
+ }
1488
+ "bridge/userInput/resolve" => {
1489
+ let request: ResolveUserInputRequest =
1490
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
1491
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
1492
+
1493
+ if request.answers.is_empty() {
1494
+ return Err(BridgeError::invalid_params(
1495
+ "answers must contain at least one question response",
1496
+ ));
1497
+ }
1498
+
1499
+ if !is_valid_user_input_answers(&request.answers) {
1500
+ return Err(BridgeError::invalid_params(
1501
+ "answers must map question ids to non-empty answers arrays",
1502
+ ));
1503
+ }
1504
+
1505
+ let resolved = state
1506
+ .app_server
1507
+ .resolve_user_input(&request.id, &request.answers)
1508
+ .await
1509
+ .map_err(|error| BridgeError::server(&error))?;
1510
+
1511
+ let Some(user_input_request) = resolved else {
1512
+ return Err(BridgeError {
1513
+ code: -32004,
1514
+ message: "user_input_not_found".to_string(),
1515
+ data: Some(json!({ "error": "user_input_not_found" })),
1516
+ });
1517
+ };
1518
+
1519
+ Ok(json!({
1520
+ "ok": true,
1521
+ "request": user_input_request,
1522
+ }))
1523
+ }
1524
+ _ => Err(BridgeError::method_not_found(&format!(
1525
+ "Unknown bridge method: {method}"
1526
+ ))),
1527
+ }
1528
+ }
1529
+
1530
+ async fn send_rpc_error(
1531
+ state: &Arc<AppState>,
1532
+ client_id: u64,
1533
+ id: Value,
1534
+ code: i64,
1535
+ message: &str,
1536
+ data: Option<Value>,
1537
+ ) {
1538
+ let mut payload = json!({
1539
+ "id": id,
1540
+ "error": {
1541
+ "code": code,
1542
+ "message": message,
1543
+ }
1544
+ });
1545
+
1546
+ if let Some(data) = data {
1547
+ payload["error"]["data"] = data;
1548
+ }
1549
+
1550
+ state.hub.send_json(client_id, payload).await;
1551
+ }
1552
+
1553
+ fn resolve_bridge_workdir(raw_workdir: PathBuf) -> Result<PathBuf, String> {
1554
+ if !raw_workdir.is_absolute() {
1555
+ return Err(format!(
1556
+ "BRIDGE_WORKDIR must be an absolute path (got: {})",
1557
+ raw_workdir.to_string_lossy()
1558
+ ));
1559
+ }
1560
+
1561
+ let canonical = std::fs::canonicalize(&raw_workdir).map_err(|error| {
1562
+ format!(
1563
+ "BRIDGE_WORKDIR is invalid or inaccessible ({}): {error}",
1564
+ raw_workdir.to_string_lossy()
1565
+ )
1566
+ })?;
1567
+
1568
+ Ok(normalize_path(&canonical))
1569
+ }
1570
+
1571
+ fn parse_bool_env(name: &str) -> bool {
1572
+ env::var(name)
1573
+ .map(|v| v.trim().eq_ignore_ascii_case("true"))
1574
+ .unwrap_or(false)
1575
+ }
1576
+
1577
+ fn parse_bool_env_with_default(name: &str, default: bool) -> bool {
1578
+ env::var(name)
1579
+ .map(|raw| {
1580
+ let value = raw.trim();
1581
+ if value.eq_ignore_ascii_case("true") {
1582
+ true
1583
+ } else if value.eq_ignore_ascii_case("false") {
1584
+ false
1585
+ } else {
1586
+ default
1587
+ }
1588
+ })
1589
+ .unwrap_or(default)
1590
+ }
1591
+
1592
+ fn constant_time_eq(left: &str, right: &str) -> bool {
1593
+ let left_bytes = left.as_bytes();
1594
+ let right_bytes = right.as_bytes();
1595
+ let max_len = left_bytes.len().max(right_bytes.len());
1596
+
1597
+ let mut diff = left_bytes.len() ^ right_bytes.len();
1598
+ for index in 0..max_len {
1599
+ let left_byte = *left_bytes.get(index).unwrap_or(&0);
1600
+ let right_byte = *right_bytes.get(index).unwrap_or(&0);
1601
+ diff |= (left_byte ^ right_byte) as usize;
1602
+ }
1603
+
1604
+ diff == 0
1605
+ }
1606
+
1607
+ fn parse_csv_env(name: &str, fallback: &[&str]) -> HashSet<String> {
1608
+ match env::var(name) {
1609
+ Ok(raw) => raw
1610
+ .split(',')
1611
+ .map(|entry| entry.trim())
1612
+ .filter(|entry| !entry.is_empty())
1613
+ .map(str::to_string)
1614
+ .collect(),
1615
+ Err(_) => fallback.iter().map(|entry| entry.to_string()).collect(),
1616
+ }
1617
+ }
1618
+
1619
+ fn is_forwarded_method(method: &str) -> bool {
1620
+ matches!(
1621
+ method,
1622
+ "thread/start"
1623
+ | "thread/resume"
1624
+ | "thread/read"
1625
+ | "thread/list"
1626
+ | "thread/name/set"
1627
+ | "thread/fork"
1628
+ | "thread/archive"
1629
+ | "thread/unarchive"
1630
+ | "thread/rollback"
1631
+ | "thread/compact/start"
1632
+ | "turn/start"
1633
+ | "turn/steer"
1634
+ | "turn/interrupt"
1635
+ | "model/list"
1636
+ | "review/start"
1637
+ | "skills/list"
1638
+ | "app/list"
1639
+ | "command/exec"
1640
+ | "thread/loaded/list"
1641
+ )
1642
+ }
1643
+
1644
+ fn is_valid_approval_decision(value: &Value) -> bool {
1645
+ if let Some(raw) = value.as_str() {
1646
+ return matches!(raw, "accept" | "acceptForSession" | "decline" | "cancel");
1647
+ }
1648
+
1649
+ let Some(object) = value.as_object() else {
1650
+ return false;
1651
+ };
1652
+
1653
+ let Some(amendment) = object.get("acceptWithExecpolicyAmendment") else {
1654
+ return false;
1655
+ };
1656
+
1657
+ let Some(amendment_object) = amendment.as_object() else {
1658
+ return false;
1659
+ };
1660
+
1661
+ let Some(execpolicy_amendment) = amendment_object.get("execpolicy_amendment") else {
1662
+ return false;
1663
+ };
1664
+
1665
+ let Some(tokens) = execpolicy_amendment.as_array() else {
1666
+ return false;
1667
+ };
1668
+
1669
+ if tokens.is_empty() {
1670
+ return false;
1671
+ }
1672
+
1673
+ tokens.iter().all(|token| token.as_str().is_some())
1674
+ }
1675
+
1676
+ fn parse_internal_id(value: Option<&Value>) -> Option<u64> {
1677
+ let value = value?;
1678
+
1679
+ if let Some(number) = value.as_u64() {
1680
+ return Some(number);
1681
+ }
1682
+
1683
+ if let Some(number) = value.as_i64() {
1684
+ if number >= 0 {
1685
+ return Some(number as u64);
1686
+ }
1687
+ }
1688
+
1689
+ if let Some(raw) = value.as_str() {
1690
+ return raw.parse::<u64>().ok();
1691
+ }
1692
+
1693
+ None
1694
+ }
1695
+
1696
+ fn read_string(value: Option<&Value>) -> Option<String> {
1697
+ value.and_then(Value::as_str).map(str::to_string)
1698
+ }
1699
+
1700
+ fn read_bool(value: Option<&Value>) -> Option<bool> {
1701
+ value.and_then(Value::as_bool)
1702
+ }
1703
+
1704
+ fn parse_execpolicy_amendment(value: Option<&Value>) -> Option<Vec<String>> {
1705
+ let array = if let Some(array) = value.and_then(Value::as_array) {
1706
+ array
1707
+ } else if let Some(object) = value.and_then(Value::as_object) {
1708
+ object.get("execpolicy_amendment")?.as_array()?
1709
+ } else {
1710
+ return None;
1711
+ };
1712
+
1713
+ let tokens = array
1714
+ .iter()
1715
+ .filter_map(Value::as_str)
1716
+ .map(str::to_string)
1717
+ .collect::<Vec<_>>();
1718
+
1719
+ if tokens.is_empty() {
1720
+ None
1721
+ } else {
1722
+ Some(tokens)
1723
+ }
1724
+ }
1725
+
1726
+ fn parse_user_input_questions(value: Option<&Value>) -> Vec<PendingUserInputQuestion> {
1727
+ let Some(array) = value.and_then(Value::as_array) else {
1728
+ return Vec::new();
1729
+ };
1730
+
1731
+ let mut questions = Vec::new();
1732
+ for raw_question in array {
1733
+ let Some(question_object) = raw_question.as_object() else {
1734
+ continue;
1735
+ };
1736
+
1737
+ let Some(id) = read_string(question_object.get("id")) else {
1738
+ continue;
1739
+ };
1740
+ let Some(header) = read_string(question_object.get("header")) else {
1741
+ continue;
1742
+ };
1743
+ let Some(question) = read_string(question_object.get("question")) else {
1744
+ continue;
1745
+ };
1746
+
1747
+ let options = question_object
1748
+ .get("options")
1749
+ .and_then(Value::as_array)
1750
+ .map(|option_array| {
1751
+ option_array
1752
+ .iter()
1753
+ .filter_map(Value::as_object)
1754
+ .filter_map(|option_object| {
1755
+ let label = read_string(option_object.get("label"))?;
1756
+ let description =
1757
+ read_string(option_object.get("description")).unwrap_or_default();
1758
+ Some(PendingUserInputQuestionOption { label, description })
1759
+ })
1760
+ .collect::<Vec<_>>()
1761
+ });
1762
+
1763
+ questions.push(PendingUserInputQuestion {
1764
+ id,
1765
+ header,
1766
+ question,
1767
+ is_other: read_bool(question_object.get("isOther")).unwrap_or(false),
1768
+ is_secret: read_bool(question_object.get("isSecret")).unwrap_or(false),
1769
+ options,
1770
+ });
1771
+ }
1772
+
1773
+ questions
1774
+ }
1775
+
1776
+ fn is_valid_user_input_answers(answers: &HashMap<String, UserInputAnswerPayload>) -> bool {
1777
+ answers.iter().all(|(question_id, answer_payload)| {
1778
+ if question_id.trim().is_empty() {
1779
+ return false;
1780
+ }
1781
+
1782
+ if answer_payload.answers.is_empty() {
1783
+ return false;
1784
+ }
1785
+
1786
+ answer_payload
1787
+ .answers
1788
+ .iter()
1789
+ .all(|answer| !answer.trim().is_empty())
1790
+ })
1791
+ }
1792
+
1793
+ async fn save_uploaded_attachment(
1794
+ request: AttachmentUploadRequest,
1795
+ state: &Arc<AppState>,
1796
+ ) -> Result<AttachmentUploadResponse, BridgeError> {
1797
+ let encoded = request.data_base64.trim();
1798
+ if encoded.is_empty() {
1799
+ return Err(BridgeError::invalid_params("dataBase64 must not be empty"));
1800
+ }
1801
+
1802
+ let estimated_size = estimate_base64_decoded_size(encoded)?;
1803
+ if estimated_size > MAX_ATTACHMENT_BYTES {
1804
+ return Err(BridgeError::invalid_params(&format!(
1805
+ "attachment exceeds max size of {MAX_ATTACHMENT_BYTES} bytes"
1806
+ )));
1807
+ }
1808
+
1809
+ let bytes = decode_base64_payload(encoded)?;
1810
+ if bytes.is_empty() {
1811
+ return Err(BridgeError::invalid_params("attachment payload is empty"));
1812
+ }
1813
+
1814
+ if bytes.len() > MAX_ATTACHMENT_BYTES {
1815
+ return Err(BridgeError::invalid_params(&format!(
1816
+ "attachment exceeds max size of {MAX_ATTACHMENT_BYTES} bytes"
1817
+ )));
1818
+ }
1819
+
1820
+ let normalized_kind =
1821
+ normalize_attachment_kind(request.kind.as_deref(), request.mime_type.as_deref());
1822
+ let file_name = build_attachment_file_name(
1823
+ request.file_name.as_deref(),
1824
+ request.mime_type.as_deref(),
1825
+ normalized_kind,
1826
+ );
1827
+
1828
+ let mut attachment_dir = state.config.workdir.join(MOBILE_ATTACHMENTS_DIR);
1829
+ if let Some(thread_id) = request.thread_id.as_deref() {
1830
+ let normalized_thread = sanitize_path_segment(thread_id);
1831
+ if !normalized_thread.is_empty() {
1832
+ attachment_dir = attachment_dir.join(normalized_thread);
1833
+ }
1834
+ }
1835
+
1836
+ fs::create_dir_all(&attachment_dir).await.map_err(|error| {
1837
+ BridgeError::server(&format!("failed to create attachment directory: {error}"))
1838
+ })?;
1839
+
1840
+ let timestamp = Utc::now().format("%Y%m%d-%H%M%S-%3f").to_string();
1841
+ let unique_name = format!("{timestamp}-{}-{file_name}", std::process::id());
1842
+ let target_path = attachment_dir.join(unique_name);
1843
+ let normalized_target = normalize_path(&target_path);
1844
+ if !normalized_target.starts_with(&state.config.workdir) {
1845
+ return Err(BridgeError::invalid_params(
1846
+ "attachment path must stay within BRIDGE_WORKDIR",
1847
+ ));
1848
+ }
1849
+
1850
+ fs::write(&normalized_target, &bytes)
1851
+ .await
1852
+ .map_err(|error| BridgeError::server(&format!("failed to persist attachment: {error}")))?;
1853
+
1854
+ Ok(AttachmentUploadResponse {
1855
+ path: normalized_target.to_string_lossy().to_string(),
1856
+ file_name,
1857
+ mime_type: request
1858
+ .mime_type
1859
+ .as_deref()
1860
+ .map(str::trim)
1861
+ .filter(|value| !value.is_empty())
1862
+ .map(str::to_string),
1863
+ size_bytes: bytes.len(),
1864
+ kind: normalized_kind.to_string(),
1865
+ })
1866
+ }
1867
+
1868
+ fn extract_base64_payload(raw: &str) -> Result<&str, BridgeError> {
1869
+ let payload = raw
1870
+ .split_once(',')
1871
+ .map(|(_, data)| data)
1872
+ .unwrap_or(raw)
1873
+ .trim();
1874
+ if payload.is_empty() {
1875
+ return Err(BridgeError::invalid_params(
1876
+ "dataBase64 must contain base64 payload",
1877
+ ));
1878
+ }
1879
+
1880
+ Ok(payload)
1881
+ }
1882
+
1883
+ fn estimate_base64_decoded_size(raw: &str) -> Result<usize, BridgeError> {
1884
+ let payload = extract_base64_payload(raw)?;
1885
+ let encoded_len = payload.len();
1886
+ let padding = payload
1887
+ .as_bytes()
1888
+ .iter()
1889
+ .rev()
1890
+ .take_while(|byte| **byte == b'=')
1891
+ .count()
1892
+ .min(2);
1893
+
1894
+ let block_count = (encoded_len + 3) / 4;
1895
+ Ok(block_count.saturating_mul(3).saturating_sub(padding))
1896
+ }
1897
+
1898
+ fn decode_base64_payload(raw: &str) -> Result<Vec<u8>, BridgeError> {
1899
+ let payload = extract_base64_payload(raw)?;
1900
+
1901
+ general_purpose::STANDARD
1902
+ .decode(payload)
1903
+ .or_else(|_| general_purpose::URL_SAFE.decode(payload))
1904
+ .map_err(|error| {
1905
+ BridgeError::invalid_params(&format!("invalid base64 attachment payload: {error}"))
1906
+ })
1907
+ }
1908
+
1909
+ fn normalize_attachment_kind(kind: Option<&str>, mime_type: Option<&str>) -> &'static str {
1910
+ let normalized = kind
1911
+ .map(str::trim)
1912
+ .map(str::to_lowercase)
1913
+ .unwrap_or_default();
1914
+ if normalized == "image" {
1915
+ return "image";
1916
+ }
1917
+ if normalized == "file" {
1918
+ return "file";
1919
+ }
1920
+
1921
+ if let Some(mime) = mime_type {
1922
+ if mime.trim().to_ascii_lowercase().starts_with("image/") {
1923
+ return "image";
1924
+ }
1925
+ }
1926
+
1927
+ "file"
1928
+ }
1929
+
1930
+ fn build_attachment_file_name(
1931
+ raw_name: Option<&str>,
1932
+ raw_mime_type: Option<&str>,
1933
+ kind: &str,
1934
+ ) -> String {
1935
+ let requested_name = raw_name
1936
+ .map(str::trim)
1937
+ .filter(|value| !value.is_empty())
1938
+ .map(str::to_string)
1939
+ .unwrap_or_else(|| {
1940
+ if kind == "image" {
1941
+ "image".to_string()
1942
+ } else {
1943
+ "attachment".to_string()
1944
+ }
1945
+ });
1946
+
1947
+ let mut sanitized = sanitize_filename(&requested_name);
1948
+ if !sanitized.contains('.') {
1949
+ if let Some(extension) = infer_extension_from_mime(raw_mime_type) {
1950
+ sanitized.push('.');
1951
+ sanitized.push_str(extension);
1952
+ }
1953
+ }
1954
+
1955
+ sanitized
1956
+ }
1957
+
1958
+ fn sanitize_filename(value: &str) -> String {
1959
+ let basename = value
1960
+ .split(['/', '\\'])
1961
+ .filter(|segment| !segment.trim().is_empty())
1962
+ .next_back()
1963
+ .unwrap_or("attachment");
1964
+
1965
+ let mut cleaned = basename
1966
+ .chars()
1967
+ .map(|char| {
1968
+ if char.is_ascii_alphanumeric() || matches!(char, '.' | '-' | '_') {
1969
+ char
1970
+ } else {
1971
+ '_'
1972
+ }
1973
+ })
1974
+ .collect::<String>();
1975
+
1976
+ cleaned = cleaned.trim_matches('.').to_string();
1977
+ if cleaned.is_empty() {
1978
+ return "attachment".to_string();
1979
+ }
1980
+
1981
+ if cleaned.len() > 96 {
1982
+ cleaned.truncate(96);
1983
+ }
1984
+
1985
+ cleaned
1986
+ }
1987
+
1988
+ fn sanitize_path_segment(value: &str) -> String {
1989
+ let mut cleaned = value
1990
+ .trim()
1991
+ .chars()
1992
+ .map(|char| {
1993
+ if char.is_ascii_alphanumeric() || matches!(char, '-' | '_') {
1994
+ char
1995
+ } else {
1996
+ '_'
1997
+ }
1998
+ })
1999
+ .collect::<String>();
2000
+
2001
+ cleaned = cleaned.trim_matches('_').to_string();
2002
+ if cleaned.len() > 64 {
2003
+ cleaned.truncate(64);
2004
+ }
2005
+
2006
+ cleaned
2007
+ }
2008
+
2009
+ fn infer_extension_from_mime(raw_mime_type: Option<&str>) -> Option<&'static str> {
2010
+ let mime = raw_mime_type?.trim().to_ascii_lowercase();
2011
+ match mime.as_str() {
2012
+ "image/jpeg" | "image/jpg" => Some("jpg"),
2013
+ "image/png" => Some("png"),
2014
+ "image/webp" => Some("webp"),
2015
+ "image/gif" => Some("gif"),
2016
+ "image/heic" => Some("heic"),
2017
+ "image/heif" => Some("heif"),
2018
+ "text/plain" => Some("txt"),
2019
+ "application/json" => Some("json"),
2020
+ "application/pdf" => Some("pdf"),
2021
+ _ => None,
2022
+ }
2023
+ }
2024
+
2025
+ fn contains_disallowed_control_chars(value: &str) -> bool {
2026
+ value
2027
+ .chars()
2028
+ .any(|char| matches!(char, ';' | '|' | '&' | '<' | '>' | '`'))
2029
+ }
2030
+
2031
+ fn now_iso() -> String {
2032
+ Utc::now().to_rfc3339()
2033
+ }
2034
+
2035
+ fn normalize_path(path: &Path) -> PathBuf {
2036
+ let mut normalized = PathBuf::new();
2037
+
2038
+ for component in path.components() {
2039
+ match component {
2040
+ Component::CurDir => {}
2041
+ Component::ParentDir => {
2042
+ normalized.pop();
2043
+ }
2044
+ Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
2045
+ normalized.push(component.as_os_str());
2046
+ }
2047
+ }
2048
+ }
2049
+
2050
+ normalized
2051
+ }
2052
+
2053
+ #[cfg(test)]
2054
+ mod tests {
2055
+ use super::*;
2056
+
2057
+ async fn build_test_bridge(hub: Arc<ClientHub>) -> Arc<AppServerBridge> {
2058
+ let mut child = Command::new("cat")
2059
+ .stdin(Stdio::piped())
2060
+ .stdout(Stdio::null())
2061
+ .stderr(Stdio::null())
2062
+ .spawn()
2063
+ .expect("spawn cat process");
2064
+ let writer = child.stdin.take().expect("child stdin available");
2065
+
2066
+ Arc::new(AppServerBridge {
2067
+ child: Mutex::new(child),
2068
+ writer: Mutex::new(writer),
2069
+ pending_requests: Mutex::new(HashMap::new()),
2070
+ internal_waiters: Mutex::new(HashMap::new()),
2071
+ pending_approvals: Mutex::new(HashMap::new()),
2072
+ pending_user_inputs: Mutex::new(HashMap::new()),
2073
+ next_request_id: AtomicU64::new(1),
2074
+ approval_counter: AtomicU64::new(1),
2075
+ user_input_counter: AtomicU64::new(1),
2076
+ hub,
2077
+ })
2078
+ }
2079
+
2080
+ async fn shutdown_test_bridge(bridge: &Arc<AppServerBridge>) {
2081
+ let mut child = bridge.child.lock().await;
2082
+ let _ = child.kill().await;
2083
+ let _ = child.wait().await;
2084
+ }
2085
+
2086
+ async fn build_test_state() -> Arc<AppState> {
2087
+ let workdir = normalize_path(&env::temp_dir());
2088
+ let config = Arc::new(BridgeConfig {
2089
+ host: "127.0.0.1".to_string(),
2090
+ port: 8787,
2091
+ workdir: workdir.clone(),
2092
+ cli_bin: "cat".to_string(),
2093
+ auth_token: Some("secret-token".to_string()),
2094
+ auth_enabled: true,
2095
+ allow_insecure_no_auth: false,
2096
+ allow_query_token_auth: false,
2097
+ allow_outside_root_cwd: false,
2098
+ disable_terminal_exec: true,
2099
+ terminal_allowed_commands: HashSet::new(),
2100
+ });
2101
+
2102
+ let hub = Arc::new(ClientHub::new());
2103
+ let app_server = build_test_bridge(hub.clone()).await;
2104
+ let terminal = Arc::new(TerminalService::new(
2105
+ config.workdir.clone(),
2106
+ config.terminal_allowed_commands.clone(),
2107
+ config.disable_terminal_exec,
2108
+ config.allow_outside_root_cwd,
2109
+ ));
2110
+ let git = Arc::new(GitService::new(
2111
+ terminal.clone(),
2112
+ config.workdir.clone(),
2113
+ config.allow_outside_root_cwd,
2114
+ ));
2115
+
2116
+ Arc::new(AppState {
2117
+ config,
2118
+ started_at: Instant::now(),
2119
+ hub,
2120
+ app_server,
2121
+ terminal,
2122
+ git,
2123
+ })
2124
+ }
2125
+
2126
+ async fn add_test_client(hub: &Arc<ClientHub>) -> (u64, mpsc::Receiver<Message>) {
2127
+ let (tx, rx) = mpsc::channel(8);
2128
+ let client_id = hub.add_client(tx).await;
2129
+ (client_id, rx)
2130
+ }
2131
+
2132
+ async fn recv_client_json(rx: &mut mpsc::Receiver<Message>) -> Value {
2133
+ let message = timeout(Duration::from_secs(1), rx.recv())
2134
+ .await
2135
+ .expect("timed out waiting for message")
2136
+ .expect("client channel closed");
2137
+ let Message::Text(text) = message else {
2138
+ panic!("expected text websocket frame");
2139
+ };
2140
+
2141
+ serde_json::from_str(&text).expect("valid json message")
2142
+ }
2143
+
2144
+ #[tokio::test]
2145
+ async fn replay_since_returns_notifications_after_cursor() {
2146
+ let hub = ClientHub::with_replay_capacity(16);
2147
+ hub.broadcast_notification("turn/started", json!({ "threadId": "thr_1" }))
2148
+ .await;
2149
+ hub.broadcast_notification("turn/completed", json!({ "threadId": "thr_1" }))
2150
+ .await;
2151
+
2152
+ let (events, has_more) = hub.replay_since(Some(1), 10).await;
2153
+ assert_eq!(events.len(), 1);
2154
+ assert!(!has_more);
2155
+ assert_eq!(events[0]["method"], "turn/completed");
2156
+ assert_eq!(events[0]["eventId"], 2);
2157
+ assert_eq!(hub.latest_event_id(), 2);
2158
+ }
2159
+
2160
+ #[tokio::test]
2161
+ async fn replay_since_respects_limit() {
2162
+ let hub = ClientHub::with_replay_capacity(16);
2163
+ hub.broadcast_notification("event/1", json!({})).await;
2164
+ hub.broadcast_notification("event/2", json!({})).await;
2165
+ hub.broadcast_notification("event/3", json!({})).await;
2166
+
2167
+ let (events, has_more) = hub.replay_since(Some(0), 2).await;
2168
+ assert_eq!(events.len(), 2);
2169
+ assert!(has_more);
2170
+ assert_eq!(events[0]["eventId"], 1);
2171
+ assert_eq!(events[1]["eventId"], 2);
2172
+ }
2173
+
2174
+ #[tokio::test]
2175
+ async fn replay_buffer_evicts_oldest_entries() {
2176
+ let hub = ClientHub::with_replay_capacity(2);
2177
+ hub.broadcast_notification("event/1", json!({})).await;
2178
+ hub.broadcast_notification("event/2", json!({})).await;
2179
+ hub.broadcast_notification("event/3", json!({})).await;
2180
+
2181
+ let (events, has_more) = hub.replay_since(Some(0), 10).await;
2182
+ assert_eq!(events.len(), 2);
2183
+ assert!(!has_more);
2184
+ assert_eq!(hub.earliest_event_id().await, Some(2));
2185
+ assert_eq!(events[0]["eventId"], 2);
2186
+ assert_eq!(events[1]["eventId"], 3);
2187
+ }
2188
+
2189
+ #[tokio::test]
2190
+ async fn send_json_evicts_closed_clients() {
2191
+ let hub = ClientHub::with_replay_capacity(4);
2192
+ let (tx, rx) = mpsc::channel(1);
2193
+ let client_id = hub.add_client(tx).await;
2194
+ drop(rx);
2195
+
2196
+ hub.send_json(client_id, json!({ "ok": true })).await;
2197
+ assert!(!hub.clients.read().await.contains_key(&client_id));
2198
+ }
2199
+
2200
+ #[tokio::test]
2201
+ async fn send_json_evicts_slow_clients_when_queue_fills() {
2202
+ let hub = ClientHub::with_replay_capacity(4);
2203
+ let (tx, mut rx) = mpsc::channel(1);
2204
+ let client_id = hub.add_client(tx).await;
2205
+
2206
+ hub.send_json(client_id, json!({ "seq": 1 })).await;
2207
+ hub.send_json(client_id, json!({ "seq": 2 })).await;
2208
+
2209
+ assert!(rx.recv().await.is_some());
2210
+ assert!(!hub.clients.read().await.contains_key(&client_id));
2211
+ }
2212
+
2213
+ #[tokio::test]
2214
+ async fn broadcast_json_keeps_clients_when_queue_is_temporarily_full() {
2215
+ let hub = ClientHub::with_replay_capacity(4);
2216
+ let (tx, mut rx) = mpsc::channel(1);
2217
+ let tx_clone = tx.clone();
2218
+ let client_id = hub.add_client(tx).await;
2219
+
2220
+ tx_clone
2221
+ .try_send(Message::Text("queued".to_string().into()))
2222
+ .expect("seed full queue");
2223
+
2224
+ hub.broadcast_json(json!({ "method": "event/x" })).await;
2225
+
2226
+ assert!(hub.clients.read().await.contains_key(&client_id));
2227
+ let message = rx.recv().await.expect("first queued message");
2228
+ let Message::Text(text) = message else {
2229
+ panic!("expected text frame");
2230
+ };
2231
+ assert_eq!(text, "queued");
2232
+ }
2233
+
2234
+ #[test]
2235
+ fn forwarded_method_allowlist_matches_expected() {
2236
+ assert!(is_forwarded_method("thread/start"));
2237
+ assert!(is_forwarded_method("turn/start"));
2238
+ assert!(is_forwarded_method("thread/loaded/list"));
2239
+ assert!(!is_forwarded_method("bridge/terminal/exec"));
2240
+ assert!(!is_forwarded_method("thread/delete"));
2241
+ }
2242
+
2243
+ #[test]
2244
+ fn approval_decision_validation_accepts_expected_forms() {
2245
+ assert!(is_valid_approval_decision(&json!("accept")));
2246
+ assert!(is_valid_approval_decision(&json!("acceptForSession")));
2247
+ assert!(is_valid_approval_decision(&json!("decline")));
2248
+ assert!(is_valid_approval_decision(&json!("cancel")));
2249
+ assert!(is_valid_approval_decision(&json!({
2250
+ "acceptWithExecpolicyAmendment": {
2251
+ "execpolicy_amendment": ["--allow-network", "git"]
2252
+ }
2253
+ })));
2254
+ }
2255
+
2256
+ #[test]
2257
+ fn approval_decision_validation_rejects_invalid_values() {
2258
+ assert!(!is_valid_approval_decision(&json!("approve")));
2259
+ assert!(!is_valid_approval_decision(&json!({
2260
+ "acceptWithExecpolicyAmendment": {
2261
+ "execpolicy_amendment": []
2262
+ }
2263
+ })));
2264
+ assert!(!is_valid_approval_decision(&json!({
2265
+ "acceptWithExecpolicyAmendment": {
2266
+ "execpolicy_amendment": ["ok", 1]
2267
+ }
2268
+ })));
2269
+ assert!(!is_valid_approval_decision(&json!({
2270
+ "acceptWithExecpolicyAmendment": {}
2271
+ })));
2272
+ }
2273
+
2274
+ #[test]
2275
+ fn parse_internal_id_supports_numeric_and_string_ids() {
2276
+ assert_eq!(parse_internal_id(Some(&json!(42))), Some(42));
2277
+ assert_eq!(parse_internal_id(Some(&json!("17"))), Some(17));
2278
+ assert_eq!(parse_internal_id(Some(&json!(-1))), None);
2279
+ assert_eq!(parse_internal_id(Some(&json!("invalid"))), None);
2280
+ assert_eq!(parse_internal_id(None), None);
2281
+ }
2282
+
2283
+ #[test]
2284
+ fn parse_execpolicy_amendment_supports_array_and_object_forms() {
2285
+ assert_eq!(
2286
+ parse_execpolicy_amendment(Some(&json!(["--allow-network", "git"]))),
2287
+ Some(vec!["--allow-network".to_string(), "git".to_string()])
2288
+ );
2289
+ assert_eq!(
2290
+ parse_execpolicy_amendment(Some(&json!({
2291
+ "execpolicy_amendment": ["npm", "test"]
2292
+ }))),
2293
+ Some(vec!["npm".to_string(), "test".to_string()])
2294
+ );
2295
+ }
2296
+
2297
+ #[test]
2298
+ fn parse_execpolicy_amendment_rejects_invalid_or_empty_values() {
2299
+ assert_eq!(parse_execpolicy_amendment(Some(&json!([]))), None);
2300
+ assert_eq!(
2301
+ parse_execpolicy_amendment(Some(&json!({ "execpolicy_amendment": [1, true] }))),
2302
+ None
2303
+ );
2304
+ assert_eq!(
2305
+ parse_execpolicy_amendment(Some(&json!({ "other": ["x"] }))),
2306
+ None
2307
+ );
2308
+ assert_eq!(parse_execpolicy_amendment(Some(&json!(null))), None);
2309
+ }
2310
+
2311
+ #[test]
2312
+ fn parse_user_input_questions_filters_invalid_entries_and_maps_options() {
2313
+ let questions = parse_user_input_questions(Some(&json!([
2314
+ {
2315
+ "id": "q1",
2316
+ "header": "Repo",
2317
+ "question": "Pick one",
2318
+ "isOther": true,
2319
+ "isSecret": false,
2320
+ "options": [
2321
+ { "label": "main", "description": "default branch" },
2322
+ { "label": "develop" },
2323
+ { "description": "missing label" }
2324
+ ]
2325
+ },
2326
+ {
2327
+ "id": "q2",
2328
+ "question": "Missing header"
2329
+ },
2330
+ "not-an-object"
2331
+ ])));
2332
+
2333
+ assert_eq!(questions.len(), 1);
2334
+ assert_eq!(questions[0].id, "q1");
2335
+ assert_eq!(questions[0].header, "Repo");
2336
+ assert_eq!(questions[0].question, "Pick one");
2337
+ assert!(questions[0].is_other);
2338
+ assert!(!questions[0].is_secret);
2339
+ let options = questions[0].options.as_ref().expect("options to exist");
2340
+ assert_eq!(options.len(), 2);
2341
+ assert_eq!(options[0].label, "main");
2342
+ assert_eq!(options[0].description, "default branch");
2343
+ assert_eq!(options[1].label, "develop");
2344
+ assert_eq!(options[1].description, "");
2345
+ }
2346
+
2347
+ #[test]
2348
+ fn user_input_answer_validation_enforces_non_empty_ids_and_answers() {
2349
+ let mut valid = HashMap::new();
2350
+ valid.insert(
2351
+ "q1".to_string(),
2352
+ UserInputAnswerPayload {
2353
+ answers: vec!["yes".to_string()],
2354
+ },
2355
+ );
2356
+ assert!(is_valid_user_input_answers(&valid));
2357
+
2358
+ let mut invalid_question_id = HashMap::new();
2359
+ invalid_question_id.insert(
2360
+ " ".to_string(),
2361
+ UserInputAnswerPayload {
2362
+ answers: vec!["yes".to_string()],
2363
+ },
2364
+ );
2365
+ assert!(!is_valid_user_input_answers(&invalid_question_id));
2366
+
2367
+ let mut invalid_empty_answers = HashMap::new();
2368
+ invalid_empty_answers.insert(
2369
+ "q1".to_string(),
2370
+ UserInputAnswerPayload {
2371
+ answers: Vec::new(),
2372
+ },
2373
+ );
2374
+ assert!(!is_valid_user_input_answers(&invalid_empty_answers));
2375
+
2376
+ let mut invalid_blank_answer = HashMap::new();
2377
+ invalid_blank_answer.insert(
2378
+ "q1".to_string(),
2379
+ UserInputAnswerPayload {
2380
+ answers: vec![" ".to_string()],
2381
+ },
2382
+ );
2383
+ assert!(!is_valid_user_input_answers(&invalid_blank_answer));
2384
+ }
2385
+
2386
+ #[test]
2387
+ fn decode_base64_payload_supports_standard_urlsafe_and_data_uri_inputs() {
2388
+ assert_eq!(
2389
+ decode_base64_payload("aGVsbG8=").expect("decode standard base64"),
2390
+ b"hello".to_vec()
2391
+ );
2392
+ assert_eq!(
2393
+ decode_base64_payload("data:text/plain;base64,aGVsbG8=")
2394
+ .expect("decode data-uri base64"),
2395
+ b"hello".to_vec()
2396
+ );
2397
+ assert_eq!(
2398
+ decode_base64_payload("_w==").expect("decode url-safe base64"),
2399
+ vec![255]
2400
+ );
2401
+ }
2402
+
2403
+ #[test]
2404
+ fn decode_base64_payload_rejects_invalid_payloads() {
2405
+ assert!(decode_base64_payload("not@@base64").is_err());
2406
+ assert!(decode_base64_payload("data:text/plain;base64,").is_err());
2407
+ }
2408
+
2409
+ #[test]
2410
+ fn estimate_base64_decoded_size_matches_expected_values() {
2411
+ assert_eq!(
2412
+ estimate_base64_decoded_size("aGVsbG8=").unwrap_or_default(),
2413
+ 5
2414
+ );
2415
+ assert_eq!(
2416
+ estimate_base64_decoded_size("data:text/plain;base64,aGVsbG8=").unwrap_or_default(),
2417
+ 5
2418
+ );
2419
+ assert_eq!(estimate_base64_decoded_size("YQ==").unwrap_or_default(), 1);
2420
+ }
2421
+
2422
+ #[test]
2423
+ fn resolve_bridge_workdir_requires_absolute_existing_paths() {
2424
+ let temp_dir = env::temp_dir();
2425
+ let resolved = resolve_bridge_workdir(temp_dir.clone()).expect("resolve temp dir");
2426
+ assert!(resolved.is_absolute());
2427
+
2428
+ assert!(resolve_bridge_workdir(PathBuf::from("relative/path")).is_err());
2429
+
2430
+ let nonce = std::time::SystemTime::now()
2431
+ .duration_since(std::time::UNIX_EPOCH)
2432
+ .expect("system clock after unix epoch")
2433
+ .as_nanos();
2434
+ let missing = env::temp_dir().join(format!("clawdex-missing-{nonce}"));
2435
+ assert!(resolve_bridge_workdir(missing).is_err());
2436
+ }
2437
+
2438
+ #[test]
2439
+ fn attachment_kind_normalization_uses_kind_then_mime_fallback() {
2440
+ assert_eq!(normalize_attachment_kind(Some("image"), None), "image");
2441
+ assert_eq!(normalize_attachment_kind(Some(" FILE "), None), "file");
2442
+ assert_eq!(
2443
+ normalize_attachment_kind(Some("unknown"), Some("image/png")),
2444
+ "image"
2445
+ );
2446
+ assert_eq!(
2447
+ normalize_attachment_kind(None, Some("application/pdf")),
2448
+ "file"
2449
+ );
2450
+ }
2451
+
2452
+ #[test]
2453
+ fn attachment_file_name_building_sanitizes_and_infers_extension() {
2454
+ assert_eq!(
2455
+ build_attachment_file_name(None, Some("image/png"), "image"),
2456
+ "image.png"
2457
+ );
2458
+ assert_eq!(
2459
+ build_attachment_file_name(Some("../weird name?.txt"), None, "file"),
2460
+ "weird_name_.txt"
2461
+ );
2462
+ assert_eq!(
2463
+ build_attachment_file_name(Some("notes"), Some("application/json"), "file"),
2464
+ "notes.json"
2465
+ );
2466
+ }
2467
+
2468
+ #[test]
2469
+ fn sanitize_filename_drops_path_segments_and_limits_length() {
2470
+ assert_eq!(
2471
+ sanitize_filename("../unsafe/..\\evil?.txt"),
2472
+ "evil_.txt".to_string()
2473
+ );
2474
+ assert_eq!(sanitize_filename("..."), "attachment".to_string());
2475
+ assert_eq!(sanitize_filename(&"a".repeat(120)).len(), 96);
2476
+ }
2477
+
2478
+ #[test]
2479
+ fn sanitize_path_segment_keeps_safe_characters_only() {
2480
+ assert_eq!(
2481
+ sanitize_path_segment(" ../Thread 01/.. "),
2482
+ "Thread_01".to_string()
2483
+ );
2484
+ assert_eq!(sanitize_path_segment(&"a".repeat(80)).len(), 64);
2485
+ }
2486
+
2487
+ #[test]
2488
+ fn infer_extension_from_mime_handles_supported_and_unknown_values() {
2489
+ assert_eq!(infer_extension_from_mime(Some("image/JPEG")), Some("jpg"));
2490
+ assert_eq!(infer_extension_from_mime(Some("text/plain")), Some("txt"));
2491
+ assert_eq!(infer_extension_from_mime(Some("application/zip")), None);
2492
+ }
2493
+
2494
+ #[test]
2495
+ fn disallowed_control_character_detection_flags_shell_metacharacters() {
2496
+ assert!(!contains_disallowed_control_chars("git status"));
2497
+ assert!(contains_disallowed_control_chars("echo hi; ls"));
2498
+ assert!(contains_disallowed_control_chars("echo `whoami`"));
2499
+ }
2500
+
2501
+ #[test]
2502
+ fn normalize_path_collapses_current_and_parent_components() {
2503
+ assert_eq!(
2504
+ normalize_path(Path::new("/tmp/./bridge/../repo/./main.rs")),
2505
+ PathBuf::from("/tmp/repo/main.rs")
2506
+ );
2507
+ assert_eq!(
2508
+ normalize_path(Path::new("a/b/../c/./d")),
2509
+ PathBuf::from("a/c/d")
2510
+ );
2511
+ }
2512
+
2513
+ #[test]
2514
+ fn constant_time_eq_handles_equal_and_different_strings() {
2515
+ assert!(constant_time_eq("secret-token", "secret-token"));
2516
+ assert!(!constant_time_eq("secret-token", "secret-tok3n"));
2517
+ assert!(!constant_time_eq("secret-token", "secret-token-extra"));
2518
+ }
2519
+
2520
+ #[test]
2521
+ fn bridge_config_authorization_validates_header_and_query_token_paths() {
2522
+ let base = BridgeConfig {
2523
+ host: "127.0.0.1".to_string(),
2524
+ port: 8787,
2525
+ workdir: PathBuf::from("/tmp/workdir"),
2526
+ cli_bin: "codex".to_string(),
2527
+ auth_token: Some("secret-token".to_string()),
2528
+ auth_enabled: true,
2529
+ allow_insecure_no_auth: false,
2530
+ allow_query_token_auth: false,
2531
+ allow_outside_root_cwd: false,
2532
+ disable_terminal_exec: false,
2533
+ terminal_allowed_commands: HashSet::new(),
2534
+ };
2535
+
2536
+ let mut headers = HeaderMap::new();
2537
+ headers.insert(
2538
+ "authorization",
2539
+ "bearer secret-token".parse().expect("header value"),
2540
+ );
2541
+ assert!(base.is_authorized(&headers, None));
2542
+ assert!(!base.is_authorized(&HeaderMap::new(), Some("secret-token")));
2543
+ assert!(!base.is_authorized(&HeaderMap::new(), Some("secret-tok3n")));
2544
+
2545
+ let mut query_allowed = base.clone();
2546
+ query_allowed.allow_query_token_auth = true;
2547
+ assert!(query_allowed.is_authorized(&HeaderMap::new(), Some("secret-token")));
2548
+ assert!(query_allowed.is_authorized(&HeaderMap::new(), Some(" secret-token ")));
2549
+
2550
+ let mut auth_disabled = base;
2551
+ auth_disabled.auth_enabled = false;
2552
+ auth_disabled.auth_token = None;
2553
+ assert!(auth_disabled.is_authorized(&HeaderMap::new(), None));
2554
+ }
2555
+
2556
+ #[tokio::test]
2557
+ async fn app_server_forwarded_response_routes_to_original_client_request_id() {
2558
+ let hub = Arc::new(ClientHub::new());
2559
+ let bridge = build_test_bridge(hub.clone()).await;
2560
+ let (client_id, mut rx) = add_test_client(&hub).await;
2561
+
2562
+ bridge
2563
+ .forward_request(
2564
+ client_id,
2565
+ json!("client-req-1"),
2566
+ "thread/start",
2567
+ Some(json!({ "foo": "bar" })),
2568
+ )
2569
+ .await
2570
+ .expect("forward request");
2571
+
2572
+ bridge
2573
+ .handle_response(json!({ "id": 1, "result": { "ok": true } }))
2574
+ .await;
2575
+
2576
+ let payload = recv_client_json(&mut rx).await;
2577
+ assert_eq!(payload["id"], "client-req-1");
2578
+ assert_eq!(payload["result"]["ok"], true);
2579
+ assert!(bridge.pending_requests.lock().await.is_empty());
2580
+
2581
+ shutdown_test_bridge(&bridge).await;
2582
+ }
2583
+
2584
+ #[tokio::test]
2585
+ async fn app_server_fail_all_pending_notifies_waiting_clients() {
2586
+ let hub = Arc::new(ClientHub::new());
2587
+ let bridge = build_test_bridge(hub.clone()).await;
2588
+ let (client_a, mut rx_a) = add_test_client(&hub).await;
2589
+ let (client_b, mut rx_b) = add_test_client(&hub).await;
2590
+
2591
+ bridge
2592
+ .forward_request(client_a, json!("req-a"), "thread/start", None)
2593
+ .await
2594
+ .expect("forward request a");
2595
+ bridge
2596
+ .forward_request(client_b, json!("req-b"), "thread/start", None)
2597
+ .await
2598
+ .expect("forward request b");
2599
+
2600
+ bridge.fail_all_pending("app-server closed").await;
2601
+
2602
+ let payload_a = recv_client_json(&mut rx_a).await;
2603
+ let payload_b = recv_client_json(&mut rx_b).await;
2604
+
2605
+ assert_eq!(payload_a["id"], "req-a");
2606
+ assert_eq!(payload_a["error"]["code"], -32000);
2607
+ assert_eq!(payload_b["id"], "req-b");
2608
+ assert_eq!(payload_b["error"]["code"], -32000);
2609
+
2610
+ shutdown_test_bridge(&bridge).await;
2611
+ }
2612
+
2613
+ #[tokio::test]
2614
+ async fn app_server_response_completes_internal_waiter() {
2615
+ let hub = Arc::new(ClientHub::new());
2616
+ let bridge = build_test_bridge(hub).await;
2617
+ let (tx, rx) = oneshot::channel();
2618
+ bridge.internal_waiters.lock().await.insert(7, tx);
2619
+
2620
+ bridge
2621
+ .handle_response(json!({ "id": 7, "result": { "initialized": true } }))
2622
+ .await;
2623
+
2624
+ let result = rx.await.expect("waiter result").expect("successful result");
2625
+ assert_eq!(result["initialized"], true);
2626
+
2627
+ shutdown_test_bridge(&bridge).await;
2628
+ }
2629
+
2630
+ #[tokio::test]
2631
+ async fn handle_client_message_returns_parse_error_for_invalid_json() {
2632
+ let state = build_test_state().await;
2633
+ let (client_id, mut rx) = add_test_client(&state.hub).await;
2634
+
2635
+ handle_client_message(client_id, "{invalid-json".to_string(), &state).await;
2636
+
2637
+ let payload = recv_client_json(&mut rx).await;
2638
+ assert_eq!(payload["id"], Value::Null);
2639
+ assert_eq!(payload["error"]["code"], -32700);
2640
+
2641
+ shutdown_test_bridge(&state.app_server).await;
2642
+ }
2643
+
2644
+ #[tokio::test]
2645
+ async fn handle_client_message_rejects_missing_method() {
2646
+ let state = build_test_state().await;
2647
+ let (client_id, mut rx) = add_test_client(&state.hub).await;
2648
+
2649
+ handle_client_message(client_id, json!({ "id": "abc" }).to_string(), &state).await;
2650
+
2651
+ let payload = recv_client_json(&mut rx).await;
2652
+ assert_eq!(payload["id"], "abc");
2653
+ assert_eq!(payload["error"]["code"], -32600);
2654
+ assert_eq!(payload["error"]["message"], "Missing method");
2655
+
2656
+ shutdown_test_bridge(&state.app_server).await;
2657
+ }
2658
+
2659
+ #[tokio::test]
2660
+ async fn handle_client_message_rejects_non_allowlisted_methods() {
2661
+ let state = build_test_state().await;
2662
+ let (client_id, mut rx) = add_test_client(&state.hub).await;
2663
+
2664
+ handle_client_message(
2665
+ client_id,
2666
+ json!({
2667
+ "id": "abc",
2668
+ "method": "thread/delete",
2669
+ })
2670
+ .to_string(),
2671
+ &state,
2672
+ )
2673
+ .await;
2674
+
2675
+ let payload = recv_client_json(&mut rx).await;
2676
+ assert_eq!(payload["id"], "abc");
2677
+ assert_eq!(payload["error"]["code"], -32601);
2678
+
2679
+ shutdown_test_bridge(&state.app_server).await;
2680
+ }
2681
+
2682
+ #[tokio::test]
2683
+ async fn handle_client_message_forwards_allowlisted_methods_and_relays_result() {
2684
+ let state = build_test_state().await;
2685
+ let (client_id, mut rx) = add_test_client(&state.hub).await;
2686
+
2687
+ handle_client_message(
2688
+ client_id,
2689
+ json!({
2690
+ "id": "request-1",
2691
+ "method": "thread/start",
2692
+ "params": { "model": "o3-mini" }
2693
+ })
2694
+ .to_string(),
2695
+ &state,
2696
+ )
2697
+ .await;
2698
+
2699
+ state
2700
+ .app_server
2701
+ .handle_response(json!({
2702
+ "id": 1,
2703
+ "result": { "threadId": "thr_123" }
2704
+ }))
2705
+ .await;
2706
+
2707
+ let payload = recv_client_json(&mut rx).await;
2708
+ assert_eq!(payload["id"], "request-1");
2709
+ assert_eq!(payload["result"]["threadId"], "thr_123");
2710
+
2711
+ shutdown_test_bridge(&state.app_server).await;
2712
+ }
2713
+ }