From 6fcdf70fee731516eb72ff1ada2c535ed308ad41 Mon Sep 17 00:00:00 2001 From: guylfe <127800290+guylfe@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:18:11 +0300 Subject: [PATCH 1/2] feat: bind threads to existing native Codex conversations --- DESIGN.md | 73 +++ docs/codex-conversation-binding.md | 184 ++++++++ docs/verification/README.md | 3 + .../images/codex-binding/after-connected.png | Bin 0 -> 71942 bytes .../images/codex-binding/after-narrow.png | Bin 0 -> 20576 bytes .../codex-binding/before-disconnected.png | Bin 0 -> 17350 bytes scripts/testing/verify-codex-binding-ui.ts | 26 ++ server/codex-binding-attachments.ts | 59 +++ server/codex-binding.e2e.test.ts | 67 +++ server/codex-bindings.test.ts | 344 ++++++++++++++ server/codex-bindings.ts | 442 ++++++++++++++++++ server/contracts.ts | 1 + server/drivers/codex-native-client.test.ts | 28 ++ server/drivers/codex-native-client.ts | 127 +++++ .../drivers/codex-saved-permissions.test.ts | 38 ++ server/drivers/codex-saved-permissions.ts | 37 ++ server/drivers/codex.ts | 10 +- server/host/codex-bindings.ts | 15 + server/index.ts | 12 +- server/routes/bot-messages.ts | 26 ++ server/routes/codex-bindings.test.ts | 71 +++ server/routes/codex-bindings.ts | 44 ++ server/testing/fake-codex-native.ts | 66 +++ shared/codex-binding.ts | 27 ++ shared/wire.ts | 1 + src/components/ChatView.tsx | 16 +- src/components/CodexConnection.tsx | 133 ++++++ src/components/Composer.tsx | 16 +- src/components/OptionsCardRow.tsx | 4 +- src/locales/en.json | 39 ++ src/state/store.tsx | 2 +- 31 files changed, 1881 insertions(+), 30 deletions(-) create mode 100644 DESIGN.md create mode 100644 docs/codex-conversation-binding.md create mode 100644 docs/verification/images/codex-binding/after-connected.png create mode 100644 docs/verification/images/codex-binding/after-narrow.png create mode 100644 docs/verification/images/codex-binding/before-disconnected.png create mode 100644 scripts/testing/verify-codex-binding-ui.ts create mode 100644 server/codex-binding-attachments.ts create mode 100644 server/codex-binding.e2e.test.ts create mode 100644 server/codex-bindings.test.ts create mode 100644 server/codex-bindings.ts create mode 100644 server/drivers/codex-native-client.test.ts create mode 100644 server/drivers/codex-native-client.ts create mode 100644 server/drivers/codex-saved-permissions.test.ts create mode 100644 server/drivers/codex-saved-permissions.ts create mode 100644 server/host/codex-bindings.ts create mode 100644 server/routes/codex-bindings.test.ts create mode 100644 server/routes/codex-bindings.ts create mode 100755 server/testing/fake-codex-native.ts create mode 100644 shared/codex-binding.ts create mode 100644 src/components/CodexConnection.tsx diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000000..c8f6cab833d --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,73 @@ +--- +version: alpha +colors: + defaultInk: "#fcfcfc" + defaultSecondaryInk: "#fcfcfc99" + defaultInset: "#191919" + defaultHairline: "#333333" + primary: "#1084fe" +typography: + body: + fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif' +omitted: + - section: rounded + reason: Existing Tailwind component classes own geometry. + - section: spacing + reason: Existing chat and settings layouts own spacing. +--- + +## Overview + +OpenMausBot is a local chat workbench. New thread controls should feel like the +existing model and approval controls: compact, quiet, and explicit about what +will run. Connection status and the actor behind a reply carry meaning; they +are not decorative badges. + +## Colors + +`src/styles.css` is the canonical runtime token source. The frontmatter records +the default dark skin only. All skins override semantic tokens there. Components +use `ink`, `ink-secondary`, `inset`, `raised`, `hairline`, `accent`, and `danger` +utilities rather than copying these values. This feature changes no skin tokens. + +## Typography + +Inherit the current skin's sans font. Settings labels use the existing 12–13px +scale; exact conversation and delivery identifiers use the system monospace +stack and wrap instead of widening the chat. Reply text retains the normal chat +renderer and its locale-aware font fallbacks. + +## Layout + +Thread connection controls are an inline disclosure above the transcript. They +wrap at narrow widths, reserve a status line, and keep long result lists inside +a bounded scroll area. They do not create a second conversation window. + +## Elevation & Depth + +Use existing inset surfaces and hairline borders. Static connection information +has no new shadows or animation. + +## Shapes + +Reuse the existing field and rounded-lg action geometry. Keep touch controls +visible and keyboard focus explicit. + +## Components + +| Capability | Canonical owner | Source of truth | Allowed variants | Verification | +| --- | --- | --- | --- | --- | +| Select | `src/components/Select.tsx` | Existing application implementation | Native platform popup | Component and browser fixture | +| Confirmation | `src/components/ConfirmDialog.tsx` | Existing application implementation | Disconnect names its routing consequence | Browser fixture | +| Approval and questions | `ApprovalCard`, `QuestionCard` | Existing application implementation | Native requests, exact scoped verdicts | Server fixture and unit tests | +| Attachments | `AttachmentGallery`, server attachment store | Existing application implementation | Admitted inline images and supported files | Server and browser fixture | +| Feedback | Inline status and alert regions | Existing application implementation | Server-confirmed connection; uncertain delivery requires review | Component and server fixtures | +| Scrollbar | `src/styles.css` | Existing application implementation | Existing global baseline | Existing application contract | +| Locale | `src/lib/i18n.ts`, `src/locales/en.json` | Existing application implementation | Catalog keys; existing English fallback | `pnpm i18n:check` | + +## Do's and Don'ts + +Keep Codex provenance separate from its original response text. Say Connected or +Ready only after native resume and history reconciliation succeed. Keep failed +and disconnected bindings reserved until the user disconnects them. Do not add +normal bot instructions, model overrides, or silent replay to the native flow. diff --git a/docs/codex-conversation-binding.md b/docs/codex-conversation-binding.md new file mode 100644 index 00000000000..41f9ae3833a --- /dev/null +++ b/docs/codex-conversation-binding.md @@ -0,0 +1,184 @@ +# Connect an existing Codex conversation + +A local OpenMausBot thread can forward the person's text and images to an exact +existing local Codex conversation and display Codex's actual responses. The +OpenMausBot bot does not answer, add its instructions, or replay its transcript. + +## Requirements and limitations + +- Owner/admin-controlled local workspace. Cloud homes, shared hosted workspaces, + service loopback, and client-only sessions cannot discover/control conversations. +- Native Codex instance using the conversation's ChatGPT sign-in and `CODEX_HOME`. + Company, token-sharing ChatGPT variants, and API-key accounts are excluded. +- Codex **0.163.x**. Official 0.163.0 source and generated schemas were inspected. + Other minor versions fail before app-server launch. In particular, 0.159.2 has + a different legacy permission restoration path and is deliberately refused. +- The conversation must be visible to that local app-server. A UUID/title from + cloud-only ChatGPT/Codex Work does not make it accessible locally. Missing IDs + fail; no replacement, fork, unarchive, or reconstructed conversation is created. +- A thread-owned saved permission snapshot must be readable in the local Codex + store. Unsupported/remote-only history and old histories without that snapshot + fail before resume. The reader is version-pinned and never edits Codex files. +- Finish/stop work in the original Codex client before connecting. Use one writer + while connected. Separate clients/processes can race the same local history; + this feature cannot remotely attach to an arbitrary running application process. +- Outgoing structured input supports text and admitted images. Supported documents + in the attachment store are forwarded as their original file-reference text; + Codex reads them through its own tools and permissions. Inaccessible or + unsupported attachments are rejected before dispatch. Audio is not transcribed. +- Only command/file-change approvals and structured native questions have a user + interface. Other approval, permission-amendment, elicitation, and dynamic-tool + requests fail closed with a visible notice. Continue in Codex or cancel. + +## Setup, inspect, and disconnect + +Configure the native Codex engine through existing engine settings. Use its CLI +without profile, model, sandbox, approval, or `-c` overrides and the original +account/store. In the intended OpenMausBot thread, expand **Codex conversation**. +Choose the instance, find the title, inspect its exact ID, and select **Connect +this conversation**. An exact ID can also be entered directly. + +The server persists a reservation, connects, reads the exact thread, resumes with +only `threadId` and `excludeTurns`, and reconciles history. Only then does it show +**Ready**. The panel shows Codex's effective model, effort, working folder, +sandbox, and approval policy. Normal model/place/approval controls are hidden; +the saved native settings govern the conversation. A failed/disconnected binding +stays reserved, so messages never fall back to an OpenMausBot model. + +Native text/image replies have expandable provenance identifying conversation, +turn, and item. Existing native assistant messages/images are imported on connect. +The original history remains in Codex. No `thread/start` or `thread/fork` is used. + +The private `codex-bindings.json` in the workspace data directory persists +reservations, settings, and delivery receipts with mode 0600. Account/store +identity is hashed; credentials are not copied or logged. After restart the +reservation is **Disconnected** until explicitly reconnected. Reconnection checks +the original account/store and settings. Deliberate settings changes require +review in Codex, then disconnect/reselection to accept the new native settings. + +Finish/stop active work, cancel queued messages, and review uncertain deliveries. +Select **Disconnect Codex** and confirm. The thread returns to its normal bot; +both transcripts and the original Codex conversation are retained. Old send IDs +cannot be replayed by disconnecting/rebinding and retrying the same send. + +## Delivery and recovery + +Browser sends use the existing stable `sendId`. The durable outbox records text +before acknowledgement; retries return the original receipt, and different words +with the same ID are rejected. Messages run in order, one native turn at a time, +with up to 20 waiting messages. The panel can cancel queued work or interrupt the +native turn. Cancelled receipts remain cancelled. + +Dispatch is committed before `turn/start`. Codex's `clientUserMessageId` becomes +its user item's `clientId`; this identifies history, **not** a native exactly-once +submission guarantee. On reconnect, native turn/item history reconciles accepted +IDs and imports missed replies once. Reply deduplication uses conversation, turn, +and item ID in the durable message store, including a crash between message and +outbox commits. + +A dispatch whose outcome cannot be established is **Delivery uncertain**, never +automatically replayed. Following queued work stays held. Review the original +conversation in Codex, then select **Mark reviewed**. This closes that receipt and +permits following queued work; it never resends the uncertain message. Send a new +message with a new ID only if review establishes that it is needed. + +Only the owner/admin can answer a native approval card. Approve grants the +displayed request once. No session grants, remembered command rules, or permission +amendments are synthesized. Stale cards expire on disconnect/turn completion. +Codex's sandbox and approval policy remain authoritative; OpenMausBot Full access +does not grant native approvals. + +## Attachments + +Generated raster bytes and accessible image-view/file references are copied into +the existing private attachment store. PNG, JPEG, GIF, and WebP display in the +normal inline gallery. Document links use the existing file MIME allowlist and +limits. References must be in the native working folder or OpenMausBot attachment +store; existing canonical-path/file-handle checks apply. Remote-only/inaccessible +files, remote URLs, unsupported formats, and oversized files are not fetched using +another permission/credential context. A visible notice asks for a manual upload. + +## HTTP interface + +All routes are authenticated and admin scoped. Use personal IDs locally; the +placeholders below are intentionally not real account/conversation identifiers. + +| Method | Path | Body/query | +| --- | --- | --- | +| GET | `/api/codex/conversations` | Supported native instances | +| GET | `/api/codex/conversations?instanceId=codex&search=title` | Optional `cursor`, pages of 30 | +| GET | `/api/bots/BOT_ID/threads/THREAD_ID/codex` | Status, settings, delivery states | +| PUT | Same path | `{"instanceId":"codex","conversationId":"CONVERSATION_ID"}` | +| POST | Same path + `/reconnect` | `{}` | +| POST | Same path + `/cancel` | `{}` or `{"sendId":"..."}` | +| POST | Same path + `/acknowledge` | `{"sendId":"..."}` after review | +| DELETE | Same path | Disconnect idle/reviewed binding | + +Send through ordinary `POST /api/bots/BOT_ID/messages` with explicit `threadId`, +`text`, and stable 16–80 character `sendId`. Answer through existing +`/api/threads/THREAD_ID/respond`. Guarded relays, app/widget messages, quoted +replies, data-context injection, edits, compaction, and autonomous bot turns are +excluded from the native path. + +## Manual end-to-end smoke test + +This test changes a local binding and a harmless Codex text exchange may consume +normal Codex usage. Run only when separately authorized. Implementation fixtures +do not access the user's running app, target thread, conversation, or account. + +1. In Codex, record the exact existing conversation ID/title, latest message, + model, effort, cwd, sandbox, and approval policy. Locate an **existing** image + item with accessible bytes/path. Do not generate a new image for this test. +2. In a disposable OpenMausBot workspace, choose a dedicated thread and the native + instance for that conversation store. Connect by exact ID. Verify Connecting + becomes Ready and settings match. Verify the existing image displays inline + with original native conversation/turn/item provenance. +3. Send “Reply with the word connected. Do not use tools or change files.” Verify + the exact message appears in the original Codex conversation and its actual + reply appears once in OpenMausBot. Check Codex has no replacement/fork. +4. Retry the same HTTP send ID. Check the receipt ID is unchanged and neither side + has a duplicate. Reconnect and check text/image items still appear once. +5. If explicitly permitted, exercise a harmless native approval. Deny first and + verify denial remains denial. Approve only a second displayed request and + inspect Codex's actual outcome. Another thread must not answer that card. +6. Disconnect once settled. Check Codex retains original history/settings. Remove + only the disposable OpenMausBot workspace. + +If the intended conversation is cloud-only and absent from the local Codex list, +stop at step 2. The service owner must expose a supported exact-conversation +transport; this local bridge cannot manufacture one. + +## Verification and protocol evidence + +```sh +pnpm exec vitest run server/codex-bindings.test.ts server/drivers/codex-native-client.test.ts server/drivers/codex-saved-permissions.test.ts server/routes/codex-bindings.test.ts server/codex-binding.e2e.test.ts +pnpm typecheck +pnpm lint +pnpm i18n:check +``` + +Unit tests use an in-memory native peer and real durable message store. Stdio +tests use a dependency-free fake CLI. The end-to-end test launches a real isolated +OpenMausBot server and fake engines, covering selection/resume, exact text, SSE +provenance, an existing inline image, retry, approval, blocked compaction, and +disconnect. These do not prove live model output or remote Work availability. + +Installed verification inspected generated schemas from bundled 0.159.2 and a +separately installed disposable 0.163.0. A no-inference probe initialized 0.163.0; +creating a disposable native fixture thread timed out here. No model `turn/start` +was sent to either real binary. Real-account native resumption and the manual +smoke above remain unverified. The PR records actual checks and baseline failures. + +Official references: + +- [Codex app-server documentation](https://developers.openai.com/codex/app-server/) +- [0.163.0 resume implementation](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/app-server/src/request_processors/thread_processor.rs) +- [0.163.0 persisted settings](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/app-server/src/request_processors/persisted_resume_settings.rs) +- [0.159.2 resume implementation](https://github.com/openai/codex/blob/rust-v0.159.2/codex-rs/app-server/src/request_processors/thread_processor.rs) + +To inspect the real UI against fake engines without an account, run +`node --experimental-strip-types scripts/testing/verify-codex-binding-ui.ts`, +open its printed preview URL, select the fixture bot, find its fixture native +conversation, connect, and send harmless text. The existing fixture image appears +inline. Ctrl-C removes only the disposable fixture. Screenshots in +`docs/verification/images/codex-binding/` use synthetic data, not a live account. diff --git a/docs/verification/README.md b/docs/verification/README.md index a0edac04ecf..e7e7a6a27ac 100644 --- a/docs/verification/README.md +++ b/docs/verification/README.md @@ -315,3 +315,6 @@ state. A green unit test alone does not prove a user workflow. Interrupt the `launch` process with Ctrl-C. It stops the exact child it owns and removes only its temporary data directory. The server log remains at the printed path. Never kill processes by name and never delete a broad temp root. + +The [existing Codex conversation binding](../codex-conversation-binding.md) documents +its isolated fixtures, protocol-version boundary, and separately authorized manual smoke. diff --git a/docs/verification/images/codex-binding/after-connected.png b/docs/verification/images/codex-binding/after-connected.png new file mode 100644 index 0000000000000000000000000000000000000000..830efc7d8f40d5d61a295a24347cbd60f8fbf6fb GIT binary patch literal 71942 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%0D(hvh9Lkp< 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 OO+!`pMv=mpM{NKIz zdCxfU;f(#^8Q#N3Zq~ZibMq?s{{LD_u?!qKselYx&Q~^aBKS@uvz)caWsFxT8 zo#TN<2pyfcic(^>AlK`o*Grok&8M!{6I0F7pM$YC&-vyjbRNIp$dUNyd-?p}a=+Ep z+xvJ++t6^f(PNXxw88E2bXU@Ak1T|)(~CyN-}~nJUz680rFDksoLKl_Qc5w>+lR;BkX{%clE8UliL_k-`BZ8fvS z+Sujs!ccHa=$`hckYUIcMP!M(QiRZ<(QJljIz=u&Oyj9Ou|o@?^Zw?hk||_w6?=Ej z1TGwnrXeVL3hxR0A49w;I#m_C9|)$Cjr`^ce+`0(J(#p)|w-3W4lCv@Kp%mcz(CNK*Bi2M9`-*0<;b#6cYp8e7LUL8&8lfO$r zye4&O+yj*@7aViRp#y2WI{hJcFb(S^pKpC*=JmD+v=Ug>x;o#?D>bO}v=c3#jmV>; z#3)e95D@p+cyHHKV&7Hwy9rh}mDl|GXyl%O`IyYCLdh>IdY;5|)%A_<97@P}G7%lK z)z;V49A@CzVE@Bl`3|YUIFqDQeVi_I(j%+W3sQ$GA;b zMh#iO@{_dhF&ceF_ZC{raNfOpMoAF_+QoxDx_aRl#37-f#V7I9k};&b8XJTxYO@XQ zjD5L28{{R$H6`X&lzVIj6;#}fwj(qzVtEWdJ?Vp|y<1X9JYTV|Wr!6z@lkV4<8{Q^ zU0j-?LPD#1s;`+2ttrQQ?vKu4Mkos(^dVXOi*`?CRL0S#SHfb<74P6sxNxE=)XW>c zvQ+J)()vaIc93Lawk`B-#)tQ|$F?K6IeF>Uv16p>{`iqWi9!sQUlqBL3CMG774}IZ8ow~Z{4^r6L1opVnSE|SR?uNf2?#OQR zJ>hV8m7*r%ioUCYCU3O{TNcyWE@lb2UjVWgt&dJ3n=Um!(W zTaC%XH%r8JV=&_!3mf}n7^RfvRSvQe4eAX%u{zf!7h?G#s%aikq@sc$RxP~)$t!%B zu{%)1uGiDd4Udd)r;K;G$X=C5bf}rN_>0Hi*V4jMl5{LAp&&&4P5nxVSXQ7T38B+7 zx^YIU-sLd`q42hraQSR+;jZLtl_j!dPJ>)m46zQkJ(~MnD2VvD4aAa?pW@@B3Vhy*xgbUD@_D zw#Ex|&a8URD#CA7ckfJAS<0jSozT2Dq!FAMh!b-Dw$9oxrje?=pP6fiYfMcggD)0}Zz_X>hqv6duwVL(G_*Z^YP0%N(8Xb@qzlC+$MVo}Af_#c(|{*c$MY`vqmR-x z4pT9~U8}Kvm49Tr_|8>F`mMd444kZMpHyo`>`TAc)>Sj|#}GF8s(tD_kjm{oz;9=C z_#lp0)c!=1X!*8`Pg^PkS-aJOB7_u(oONHQt!gO`ZN zU!MOWxV#h3ziQ>mAtx`N7jSCyVn^pykA^=ECA5xFHqG+}Hj_H8-#6nQ9!|{*9c>Jm z)Yy&5N8*r&gwHNzxreZ6Vav+Op8THad8?8magxNSY%`ntZB;8R4yyiWL^VQbMZ6SC197b}bKiiJfwaD~jdZcn0nfdM3q7$-E z-3ye0j_ca`?=+TN&~taLYnwmd;<>^f{7Tp%#g9I0FEk1^aznp3|Fh1)EI6d`l1ls; zpP=BAUboYo?*|75fD(psBxfpQRi?lC9PNDnY!2<>`=_Vdj}xGM%I^4||M}4p;J4oY zhEl|-$`OULt*s5dnz=28m~%2?mu9Dbm6Nsk!%6+GuMcM)RB#*2`@Yzo?8_AX-5E`& z=fZ7Vqmsgg|G`cfO8`l z#gF&rmZ^Q4Q~pBKDiszFi(=0G&(FTikvv}gGQ(UcAG=~`pk|HFo=z5NW0e7XF#GB~ z=Kt{Z>Hu%+&!cG8+#=IPfq;X<9@^6Bkqp7jGNaFA(&fgrC5F`nCsQnQ*`(aYonf^6 zRw#K*Qesg(YTvxGBwmzwPSs*03fYfCr#U=W=~xQ7kv1B$b?~)=9)|wIe|xfcr6Y2p z7E8UGnXnR?u&B!dnV`+!JLQD_H=LUbXfk}YWDL-aymY4fl39~d55sBzpp+O?=CYT! zs^5(!WUaehpXRRvbH4L+&ZUn&Jb>HMew57%7zOsiPS}dnTug@B@zxm99Sp;sH?>_m zz7xFk*rXlb;s$Z-vk!~I>-Lle3GS4vz?G2UOa>#qSEBdL?4}TPp+f!#pw6Vu$uib~ zatrn~;H1u1HtcUn#Y0fZ!m2~!s3lXw(}WgzdnZIfaLYC)XD&qUIvds8ht{7` zQ>tGsP#=k8C()ZDS+DoZxzSlodNRq3#EDYG;KT$)0>bd4GaEsqD4mfL_ns z9%GQFk`}hwMvkPH1g8A(_WgNx9uqDE`hcoJcgD|IN&Be({Y+Ko?hl7dj3s3!vWK}ucQLD*2Wjy8s7_-Ga zBD#CZp0dBH;u?KGX*gleJd^4;`x#DTrm!Q!6g1>G>s7i~Tp8-f#5t0Q)52Ccr6|gb z$I}mAX>yl^PDdGCHTd+kkX97>)=Mn%l2EuX^~}a>J&QHmQBp&fKwgU}uLNX0=8?^A z-Xewm_46u|NG}|PA$MRUWd1x)s|LtF#*cqg%aB8gZ|1FzvGrb59Wrz`XFH|;&amk* zA|3f*J3IY*xb1-Fq~M5@PLUS<1{AR#pm?JI^5^2zC`6pL3>f+PNO{e!u8u}Z>veU! zQq51l)I4>6jm^i*O)UBW`|Yp$oQ-v+ks`8{0Q*A*GsKI;?^=GX_RN0#%HD6noSv~W zZqU>Ii|1~?>gH9mV?K2aznT>m;_h@BaY>w+g~F%LT`xPX7wTQ}R(^TAlvlHM_J5lT zNqd#s4OlO%6Bv}u9c$8_%M%~0BtUEv&I^<4&j_k6<9MFW`Tr7l+E0h-$vZ4w9yV+EsNuL*O=jU`Csv&!iVoTSpb_h#6#1%M zq;ai7HI2(cgSH8Yu$(bPm`g!xL!vP!`GRPUSEvcI!Q~y9bqboB-ik{xn>D-){)F9& zB>dH!&=;oK=FYGzI`d+~a+!7$4N2d5upK#kV^WXVqhhj;zg2s2_l`=~#gU=h)nD}a zCHmvbi;kC{7J2c)yhFbpjdOD;EEI2a3qPQqFy8%uE+@qzb0hkMC?witHBtC~Y60=o z1J{E3(k_i7W;t}7_d4_OP})e{*-&Ih8B1jGP_PRq;3M6CJ_Z&=G|*fqtx_w#wxdQE zm#oIVcTG8d+1m2;d^r?%I9K}j(UML2rqJ#=KiXt}3Z*83 z)ePC|vOw!(FiUhl;ofyG96rEqaP0pr-CWN^xnF&zID7wQ>fQ%hF}GzX*4_7BVf7PW z$JMPF3~DHKJmhUwNPfevgZl5|bz_dkDw8K8^496@yrrdOM30&}6qWDO6^|(&>p?F* z;!lSnB2eW| zxgtf%pmma$D6cIB-TVW3T=G&|CGgKr)eG&ZaHoL&Pm~BOHEuoe)CSN_t-3HWFNSj@ zpI7_3-Jd`4Y_@g|V)FU>!z&4qk_kWwl)L=GZE;S{*a94C2`=bOuqe1MWM_M94y%`rznr6wXi-77uW=y$0#TkA0zI=yoEGCy%^W zP4<7{MoSYzwX;es9#xL5{S=#-h}pfM=0lU~BWFja@1MFFvGI{l+TOmeC1lkYy!y$s zmfN}5cBf`6IeU#!y4y}y#wsi<%xUrKcd1ZW4es=~ZKk0R7p0c8IeiuV zAZaj4KfqppRG@&)(|KM0o2Y4O#s>j`Cc}45eLiCNu%971sHBZvN+ZnOo~mFkUG^D| zdCQ_kWFl(5^gA z68@HurKRinc%c{huA$kz>$7(&VgUu{H@czn-q~nt<6;Uw_(Ojpr1H%fU^@!b^e@Xu zr9A%1Xj{!lThtSf&9z0+pQ1L5;qon`o76_pO>;Q0c~yb*vI`KG_4* z7fvp7mfNLgj5y&Ey5xTmFEQ_I5~8;UuyHi;==))Bxtwa<{M!$*W*1Y-vt!_(t$c=4 z_)}mTr#$v=*Oz{rX=rZ#fhU~*<9F}M>7JvkM3;zZ54ZVeXH8ZJXU~rw#QLRDcUpXZ z|7o`7Nv->uQr?rmsWI=eu_FJ@WrIDf^s#JhLT|zbrftqkM-C^H#ip1^D#Pb2sn59t zoUZc%FZ!hHXV`z135BnW=?pr**x^G^303USM`9r!e&u;_Hm|%F{VB*NhA;i~K#^p+ zVS@=5PJ^MtO*dt0yZrYq$!8?&5~;VLWo9Lh{O4qDf0rvCtHIo<}~%Y4j28Lm3*~>KkgQ~@ZeIhRko?4uyQ*Q zVo`~W`f;{Z=xm{Vq46nV_ch9i<;x_LA@WrhpPT!&=1SYh`t8Q?04HHhAf2Ftzr9Ej zW0L+-)QQ)G+vmS7?a%ONKom!wmYg;LYrpXqFLS$~6CP4?gL#T!=b}JGjn2hJ4_2R5 zFS-PAkHIfzlVj@s@5$noo{~yO=jmc5b^vrx-`FCWs++U|NcHpnd;y73YEma$-#*90 zvQQa@jjf#Mij|w$JiA)p0|leyuLo}i@lEEtOr!dX))9X^U>%R}Ose(2=#65qvQJ@k zL3;6PJUrlsO80qeZRh>$BJZUL#EyR|BBw~0$JuUCA8?<3cF1pE`<@=ZBBp)#2|GNU z$y3os&H$T??|yUNK$Ll>A^gxngq#|Kb}owSfntFaddOR4-B3C-3I$=bkhik``FM4f z_7OPE*W>Yr>RBS{*<#ff_a@Tc8-2EcqZQMxMOz(AR-~Ikik~IP(2ca+>38MFc9~(D zn?DA1jIyJ0iLf9VI?POwHX~H5zxzwLn3yIl{yxWDro~XugOTnOh@{6LZ$kq1;r>0e{5K{t<^Jl99^?lCci@f$z~+;6!YJ_sYY0BM|6BfDt)>UctvLrVL< z2;nzh#nooNzZXCG>_+MSd`tlbYrzcSIHx>68ygxq1}RT$$)lUucQm;=Qa4vepjZMk z{CY9>>FGlm+re~M7X4uI`yG+E1|#2U?Z#I3el%_Ki_!8A4uYJpW6JK#o>ZB#67BrK zxq@2KbClh*7WBO%d@N`Dn~yW0p+?<(wxHN-4|?K8Y+5Qf67LA8Wr;Yib;oIQg6?ay zM*lFCtFrlYE)1P89&i%R<1h6~fmz)jFW@QYITKr|2V@Qyhj~#5d~fy>%{Y_dw>QR~ z9$+N8uhpQ3ZQULFYoYN7h=P8lSsZP<%8U4!&R`EV`X&`I8yrvm%xvT}J8LR&l=M#> zL>-s!J^YzOEz6KfQ}Cy%%dKRnl3y>Ws|Qdl@tnODd6Z22d%eaa3<*ui!i#P2rJ#_+ zyC+~J#FCvPlUMd(F?VWN=)%IsJy!dw4rhgf0Egm7Y@YRoMYo;R^9tkI{_GDj!S4PS zQ?Z0y)#PivvKEqR{9oNGQnGB&%O2qjeXzo#j}@+aqlv$$Ko;NEgyBhE(yPKGmph(Q?fuFWJDZB1H}lLLLCEt_e#<1-xQ zC#2{-gKaz*T4B`*W&H6-QHg7h@m;An)(ef0Ds(5i>wIHZ(ig%i1hNM5(H!5iaLx_< zK%*lIE=El|%%qY-Z$zK|iTAEgT;R2EcT&zb&tNYqx1#^LR&AR3?ZXJ`XL~ZPR3W@Z zjKYnr3(W}V8TY9${@2ewA-%)a{!MI}OxY935qV#1O^aQ0h`{hefmEH|V5GG`bk(K* zwiT&=A4PHB`noY<%~%0}Sg;{>L-CG?;9pRd!mVsDjem~{2$GNX&no$>wOF-q{2S^E zxI7Jyh$yiydtAXD?i}P&b+Y#(%dFEtchmhP$OyeO!oSHRpExO)RQ{;3C!ELLNteKs z^4c>+RcH8LJjh=%A?dajV@nC6imV{(D~gk(&xo^i^Tt?O*rn| zQR(d*iTuRet$n4$Agm}l+g{C?@=%waKOTktb!UA~>8NK%1a^mSN;9);6c)j{DhO9h z{mFD_zW=T2{BQk8ljQ)eK@X?~lAb$u3U|QHAOm#)RE}Pc!_}^%&5_OJWK9|Vuq`GB zN$n`TvwR9rLfpqYK99(h4g*z=s7z+nj6ayOpD0Wk_dm2uIf6DbS*)|UF_Z=1c=g-5 z-Wa!@lzb+qEWV1uuj_x8Go=P2QB=$iQ-um}PHg5tj4AdK5^|ay#Vg1w+Et*}U#XWr zTy6`s>WUF|oF2SLw%4bU@^%Be$hGCXP@>iM#NnwO=v*P@USK$Re=oT0?BZIkhLOOP z4o=Pv*TB}(NM;$Tvh0|yG!HsEuoUpw%<-z>eV!#~3;#nMYvkdcrf)tKM&Kic;rkw=B@h z<^kR+6%rzszQW5oG!ec!op*&>2>|?>%%r+{qyvJ8$Isp*5Sb$aLF>2$#6dTgOW8qt zjhokJYq&U|;;DudD8BMF7k=7L^d_RZOBST%bJHeIXK*!P^}!Dt5%aq~-y|P}+JAMn zmQc>XpFH(2F5ORD%4wYT{}BFuuT{zObZW!{+-PD?Kk&KmIH$T4XP1^pJ5_8c&ZD}ia)RvwObUK z8acPRLjB-iMP7+UG9wLM+#=)EupY?VKp?~!4U%G+tx z@uI)7mx^$B<7uRg-`+scR2s|0nYcE(We>xkfb71^7U*%Cd@VTH2_-*5_^IH~vQS*M_l z&3*w@Q&7q^#eOSI+AV-56(j)k4>AQDR1zv`WI47?u2^RrBfgL ztp4C~os(g+jn5JZjuwU5u4S!W`?(h-Br7ko28aT|EmhOiMXo@pLp6TcqI2)gbTs0O zVpJ{4xU@lqDY>+-5UMj!k-=mv9eu^CbC(}q*87reL3xe%y=Pmt)adaM#O5N(mS^SX z{kLi?)eUKF%K06Q28xTH`fzf=H*Mot2Z<4DBz9F0Iwg(|T-=8z`P~ox$^=~;+sjmM zHy-uNE`Ify@;Q~nQZ9JkxCpMQ{)tA8#O+?5r8}lJW?9z2YT%J8W4=E$%nygmn#S9X{{=^E*Gs8YPanu>|)4{mQ`*Sq2U zx!Qq)g~K-4-&3V&D?UAcyW=Ral^K>ycV>Vb3IXzJy}7=Oz^23+*%Lrn_#dlSM8Su7 z@8-&5U(hf=)}U_n`-IhiFFdQo)=xt2x#fX_lK?P`1a+GnBYKt%9%3t2m4}ZE7E(yw zX($FKO(eN7^52~Zv5Dxzeivqw*P2p?C?2S;y&S~GfL$o5C7xRtt_f1cb1y%Vdh|(B z#mI2OX?0u+jC6h}PAT!KoW5%+5}70a_Aqp!AtzA=hSx(~u}|yo#Hn`mB+v$J43eL| zaVJ3D>+GFWjLPYi=oPPC2Cu^}ndiN*4lsiLr zJarRjn*d6&C`Guce8p=h&%p_~gwC`h4Om~>lWmn8^rl<)rZ;H~lsP9MH;V`xYEZ<@ z16Ch^nNbT42i8*-Gsu@@h~I)#fWaAoOdDtI;g}q6zX9CdFFfAN^^5P*0!x8M8<$8x z8iR!E45Xtv=x>J-PkIy41dH`br#}LJgt)=8eDV)5$v>|ugZ{lj*8)X~cz;k!U7Pq%yxJ zi;v*xHASN^cXA^|v+chA(c}dr2`$vCF(ra zIRnig=8k$ex|;=H1iV5IgHv*RKD9|;Y9&*uTU>z6UlOB^rtp#Cw~6Ju1z* z^)$bXo0zzT`Husq%hsb|N!R8Rk^xLi%$7Y6{p?Y#x>}w-)^nBT_v+Pq>##64_D{nEu}fMQkGEAp5TnO9+iTjN^;#wY za~P8eAsbH&DysLd4{0DmemC*i$gl;ZWs7!*rj0H4a`|;tb$iYLRF%{KY+@FZDA6c+ zdbAMURV`rh|2I#SI+rAmu~N-RO%;3|0_oWf}kU%BB} zSjCe09}&WH=L49n#c2Y6-Xvdz)kfsIP&ff^a9(I|2Nbct(D=DK^l>!w3aHo^R}MOIYP?l+kN#ko*(nBt|&LL}`=m<9xcm^3cI z4`>0v0v;|T4PAjRa^wMBdySj+Q8gKrH?!K2wB+j6j!w4JhORP4l%P}#hfUmeD6{JT zQY^#~gW%C7wN;3@mMJCxfy*DPYu}zK#UA-h3`}U~KC660i{^*kr2|7XLEB*|-{Yc{ z>O62Q`d{)DAeK69OIf=B7Xw~Q^B)`gx~-XSQO|9HdKy*qd2?vpcz+f2V-CF%Ce7UJ zVmTJ9e0ao-T$!pG5lnB^z+VH{7Gn|b=@ukLJnn)bd zZjq}N`D!>ZkZSQ|VmMjT83E76#P4P&n8$oV1As(5UJHo45aHP`t0 z{$kEkH;C&+DlZ!x(9O+z$PkecBU;HNcj4>yY|J}$QdnBu=^?ZX;er7o$V1HJ1 zASOhK)9kAl;e5c@4o0atKbh;|SNoXYW($SQS&#ek6d1=T9b(TL`07^b9W30x>39CU z1fDmTtV>qogy3!P3qo3v~v}9Oq(27v$II(io-&OJY9t57#| z!dBk#?WvHhbNONX*+#w7g7dC2yMUq}p}O_RfwxD*%_u&8e217U-OV2%NWtxW@Y#QB z!8pdy;6qlw;3V(PXDA*`)0Sh+Z^_QL@BS<|j$BPxiQw-xWGY80=R(ei2afhzZ#Ejh zu=}}2_vW#?jJ+v|vjMxKz%+lvJNz{*9kpY2okBX+9%D;z*g2t1M8V<*z%_5fEg5AULv@;SxY4rgZs$f`YWYF2HX z>!fU2bhpDh#pEMDi+wV-s4y{$Nfw(1XUOuAwvZpEkRQUHnbZASDln!3AZWJHHnJ;9 z=awPRZ(IIlh0Sp0?lkLBueaOSEq=8aL4?wl0g;mGzimJ8T)uxIf3j#9ZSw!sAgy@% zlNm0MekhP(t5Wv$R9a}saWS2SX<&%a7;sUA1uFQIfOrk2)KDcni zukYx|4_|?ReEfTcSGzz#o3gWSa9V{=L79ll<5+14?bu*vdD$Xm5-@fWc5&=Wx62@G z=#f6B-)%!fLLi%+4h1#k0T8>PNZyuovWxF)tD5aV7vDfYUKEW zQJoi3X6csQ@t=DVSFjb3(nx7N3@ZE6!-VzfHjnUckRR@86HSE!d+m)76=3U#ixRg6 zn>rlokIkbMZ+2Em71{D1)A4cwiLype0|f~}Z|PEMiYM<=^)#G6>@aANU*FCDgoD11 z-#dwhjjhMen24gys-F4tadrpYb3(C!#;#duh&Qi;;x=he$h~WQmO$&p2CX#s5ua4( zOC?#!qDrn`R80xd3I=2S_pS~?^?1odRMgM$LalL$DnOu=9X;9s9_Cx+^guzu7sA+de1iKS#*@_TK5m`u{ zP7I~rCp-7=VzYWZ5zk}IV7Zu>OI7p1uwz*)a;SP0FQ@PSyef#gzwGd=_dsC^w=c6Y zD{)`T`EM76|6of-mk7yP;>FG^zDcI_0?N9i;reqLWShptmG`Eo`H$}6OOrAUvsgicE5O*B8B%i)aL3|$qsJeS&sJ6fv_&wOov zkD5+nzWe%SM^5p3*T+Zr)zv1YdZpc{hy#WC8ch#;M3*^mHw^yD7A?GVun4n$x%?cW zZap8xYT2!+!(5PT?I*l#!Y* zR;E)LDhYRcYvd<;$(p3bj^Ib%B(+Lp@66|OgPEYXHrEB0^pNBSAEaQwM0%TGP-eJd zT7;1$FAAx(EPi~;St?sq-78vC`ZrmcSG3)-P~ofF+os5A6?TG>UF0M5J!6d-XsiCJlhl=OqcZv2KZO^cn)q8Yux%Aj(TZ8b*j=imv^B z+V?t~^5KTOU&Q8(PdRUH4!^Y$aGj}%yU+uj$KUrs(+LEEC3rmoGW)0)Fa}W+*~p>@ zF$5NQK9swHu4jdYPN|LU{?c)#Vnq_pU|3^Z{A`VV^54o?B47aZ zGTmRmtKe~(sh~azf}Rz@bFB$~U?&(I0?)fOWkG&fs&zE5kPQy=j)3S=8w7FSIv&^9 zX%httr(>fcadc3Wqr_=7xUGN}Qe^AByLxj*sr*m{Ey^K^vT7{-UeNWQ^#R9s7FEk& zonOKX$zSmVB?~_RE-tkIJ<1H5x2%HK39armVPP-f7a$6nDsFZ8kFtDo`&Rhvsaq6E zxIw|{O;M%I6|G==sze^oV+nbc1Nd@5sOvt<--128GTntnDMqA@Vmml(cmCV2zXy^l z7fgW++e26E*;~6y>pxnkP6@v*=3Th(Sd*8lrw~KzJ7y-7pZQL0X(+_X4@VhrpJLwR z0Xg^y-U-;x&1!pgC3e5%ONOlsx)tf-jrp2?`#=)zp@&;wewZq7nHkq zaVbS&LqXRh;x?X)BAtYP1_u73Z9!!waH@OW2g@Ldu9_z5y;oUnQL6s@>fdE6DzTPI zGtq*11P--Br_~h!Z|aQV0Sgn;Pw>wG{0XB39cT(WEi@c{x6ON8>+r_cY6RtoUZ+qE zmqrQ=e0^|N7MuMfUu>tq?StNORO;Kz%ef1>;Smrbc)F0%Ex`oRnFL)OW=z04sDmvE zY3*j{Gr#MjEkG40QUkZ-bh9aRjEPc9)&nMGjhQnadctA{B?Kb+!tk}C$}n}e0QTJ=gWYq}3BY+e6R=~!p1B*h=F#o+8|0n0h?+SP!v1G&5u_4Gck*r6 zlP#fi=qCDvZ`OM58aod2T<*YAR#U`LiY2qmg&Ok%){3njN*5?f<51%ZZAg)kY9E%0 z*p0p{^fcqJzdMN$nxNe^b898mpsytysb>m_`<-&dsUBVb`@3Wp1{gjUM+YfZfpl$X4XN zjDd#KV6r{&UQGdSEU~fkk+qjNA@V@M^Izjs6AQZOU6DSeHh;j@J^KP8{)evt-;Txh zf{`nPr3e@IjuF&vPzZSC*P1hhGziKu~mwDBr8MxY22(LP)?dXF1kpGNY+Sf(}wM_q#49Dfi?&t zBV%fG9B9#tLxZ(9j;?#OX)j7~4r$H%_K(yfzbNOTUG}O)O!nN>pCR-gfF?!uRNWb){okHUB5 z637tod)M^HaBf{73{_#0{@7;KE2J7!*308x@^{2sHQGJ9NLv-@HB_;d+Y7vHOj}(E%k*oD#9k zEkB%`&0&1}^{}SAPpO6^*^|L5++41l6r0Zlx)O znJc<7-3_Ex0g=R*>w^6)c7od*yb(S_A(BISq8F^{{$en~`qZJzD*HBX9)PG+XWUst zEPa~_b5L|S8{rb3#?-#wDCPE6gviuvi$;q2nFY@BNg}=cl5e+~+OK0o99G7JX^Si! z%7!r>TShIiN>C>Y72?@b`t0YvAj&I`mY>Z>*mi#QidyZ)X@6FzCtfV0j@X~d{ffCg zvy22`cb#fNc$;Tm8rKFV7O^{m{27n2w%<2=mWi0K@PT0p!tr?}n~|ubk(j8ASYbuc^Z89 zez?-4zM}Z@m8h3Hb$N$vR`jPp@Y0?oE}FI>fJ!(UW`@wq+y$t2A&s==zA(_!5Ta;$Kq- z8owTXG=L+au}iCiS1LUbDsGV~7g>P!-3BIkYkxhp<29(Y!AOeDqOdV?N-CI3T#CAu zB;wLWTmDS#qR0vHWR~2KW*UJ`JnOyci87_8y1lXBS~{+G09iavqb(c7|Bj5pD#J1SP*E z?(5)t4Ed&E-ou`_O84E~QK$9$?Tf73=GTuQi!hmUD!KgF_?nob8w2@fLs%>=P6m(HqdW7a$Y?4Ndv)8iE`bD+Jb8wK_dP zq3DkN2SdNTfSy)D+Z=JA!TgZ^`bok-sQI zlCZoIwHy7mC$1r=i{HbcI|g;->D3aE`Kwq21l;x8_XSkR0VFzYLAhZtZ}E>vT*jZm zD^-Ot9WOl5_#rkY9%PB{;T)f=VO$?(VlKc6!KSxlETPvYq@me!#%Fq%8Y{SMNIBYv zL4lIiNm0@p56!Tum>(LjyvpqD9Rc1!RQ~pyp&{od)dpVEH`}~%N!_Fv;T^2Myr6ac zCX~@sdHJ7z_|^LmKpm{wW`!)+S+iQYV?w)t0R6U$Kf=`2TEHX9l3pC4SMpdY^zhi} z>jWLAxnjeC4A%pO0-PjBjlsQ@beok1T@Lt^pmiS@<-{i6D84Q|sB`+Rg>5B(79o|A zgI5*7F+L7$5i}Z?`MM!i9v(%zbss?{UoX>sx*V;0$2NpB8p{2S_$Csb>;>_-6RkV>2GB zrw7L&b5A9eiyWg}lB=0cf#60LSw8XUHgKK@JW21Z6#$Y&PQJeYl7;fwCx^x<@8;U-gWu?)sAtI2&MWnPQwV`zuWTXwcEUSdYzHRUI4!K)J(;!LK# zJ?m6TqwjRtVmxY|4Z$N{sNh@pm~x3wSp-0 zbp#V7mcS+<+9Zr38}&osUtGm)khDlEI23}kW&nC2(h0=Tq&2Ylq85@aN>r9`lpw@b z>qqzme)87(d3!$?P%Xq@{o!fC18nj?|3r^=7-CmV0n_HWjLPxQB~Nze{{jSpf8ht? z6S3Z%t=A`&=8&R-GYbB?18mhLh|vJ{hvD!Uys$GlAqgU`d^js$*33W}!q8$$)ipAN zylVzrJP4J!ZhVEWodV|a$>+#sFb-xkAwLkD><`ckUd_-90l(X1fnkx}DomO9A9j&q zl5hdu{synGfKkCce;KGL@T!1wnEB|v>Y)u9%ohLrI3JuzF5RNhZ^6jliSDc9m0-h= zD4bac7t@sjoo(96_*r=(4KZq7o_ z5*^(f0k39&*8Qe&CEydFJ17X&`YF8Tfm@^b{dW;J^dRf&_jm8{hX=$wPXacW2;ZnT z0a0lF)6Y3*IG%p4TW)*;R$V!~;Q(+}>%}(b(Nb9L=Do|E&rqpaz@oNe$j$);z$~Yp zjbt^FKV=F9?Q z-Ueh)=|7QdBPbq4i(3Bc)6i8z4lIFLm?i=;0Yf%2q==rB(U@cCZ3X$=zP$i=prw*h zgy4mChB;Um|0%X9L-$yQbm8^ZyM(G_V4J!+#e&1ON{#oL!$7#AUzEWjx2GWFTj6IPDXc96h_e#Loe-3`Yi zosJ1}ShfJJckBM=c0ryETQW#@8u5z;RY>3^& zdI@m7f>%utvp-S@fMHy=JSRl5zyg7u`@Jh&usv$RDRL1W%=u6DFM(|EThp`PnGE=! z-VEDP*HNEUA~t>MKy#@uYtAg4t8;cllN%&xE(t#T2Z~odMa$Knhk1EoeK^e10vu~l zIUsbD-RWisL;T?0!szv_L%6U5MbO$D@{T!=(W&+UoLJhGnbZ&Wpt>U;Wn`R!?Nw5) z&gHL0T!!wP&m?BtV}Pmjj0Qd5K~vSP>q?|zBw7(A7QcgD0{?n&keEhwnKR8`*lpZbb< zG67XwVhQ-c&IzK!H4y9}(Rr2Z^y`)+cgK=LfMoNE()Ft)3d&!Kq!?*5{p;IAMLCd0C{%yE1qFP|5g{h29i@%w5e54=p0Xr=iiH=wC04{;aa2U*|Xz?^tQp z`~?znb@yf;3A=it$zdh3L0{bIP)z3m=fUMQdII$?cKSbjF+a&M{+}k{p^B;pqU%vz z<|pi2Q_JIBweLEmdB1l@ir(W?Aa>CURp3t`?3F|eD{3!y@IE`BA4vL2#V#VhKdMY{ zW)ekkH7nBqW@&*!w1Q-(#ullsvKcRn0`c`OS4n+2%)c_g!hYf;r+iDgh3VUQ_R$N4 z{=tFY<;xe*D^Z$R@aTLf`>i7{@x}1j?i@10OkQG+qEbFtMF?Ii{>S_O*O|ZnpAGCk zKZfT~VhNT!6eSTP=GSRGFGO4yslumjekN<)>O5#6(va|i?h3~EKL|L0&B$x^wL#)L zjN3w(Y9AmCbo7n+Y{-uVj(5PDYjTeK*WyH>WWBh}sEHn7VPL?}=`57hM!-ri-#z9{ z#fJ+o`WXYw2~7vcxdjw@Xc{+gf^Wyspz<+x=a&Jt#U zDb^4mM8%~sg}f`Ms_B&-fQAjAoPfjsE8DU}w>XA``vOkL6eO^rPi(B`=jX$r@_~Fh zh_R`VV})JWW0#B%}2Q_sAFc0?)a^7Z=SH~jJQ59GXJ z9X;0jyK0;u^b7l6#dBwt9}BN(z!EYEvn)&=GYX;z`Xx&6HpNM-k^94P_pOaksgwG1HJst44%Kc1p}-hITR~ z&B$~?INQ3647N_nJ(>(@q#_f`I4(0tM3KxSd2`1wBtPFXbzE!WC`^*N72qOoioqj>>dbs2=t~?!(=V$VMJHNFY@4AAJ8+`fRYkq-(=dhIeHbH z;~`}viFfp#psE8{&Ycx=g%-*4S~Vk|pJViHNoWK>{_ek+gjaVKdze2{?B#HVw4l%>9ub%<78CXB3p zA@8|RycF{osiqSwyTEyFO{0Pd80jjS+N|_yDd{0%w@52aIaoHJJtXb-ehQuW0y65Z zlppy+d2vJRG;=tPV}^0v{vGPNd*P|9L&}RU5u;Nlk!)7n^?)w`@81n^h6D=;o8(UZ zTih0k8p<$6dWK91=K5)wWk*M&pT;(BYqZlKD{%*;A)TCx6;9SV$7k~?mhLGzsvG_v zo?&+6nV8~PS4RRPti`aMgZ2^fiT3+Gly&plCeJ0SGp+abOf4QUU2L$HLvd33)cWGo z-6JknGRIyZHE@8co5;69=YBf3baR0Ra?&X#hy^>`nB=e6Y+2ThKIDGbTnB~!T-`0? z#8-MLV=1BSA=7QgZs=a0;>mlud%K6he7TU9uNI%_&o57Bb;Fk7%9zJT>&7C$-_TxA ztyOKmZ4`eytL)84fnrBeC|uvOmw_RF*zCohHQeglud}iEbWt9t_cCC*yL#hAb^+4A zWZWa0w81ZK4@9gr@|=k0;Q0+YY@i&sBZL|vLgDjtkq;6o~_OPKc4t6$!IkT;%J6u zsGnge#w%>O4jR^T*e(s)=OlUv#F$UvhqO8?;0V~RKtrNgUSop*!MIh%t8MvqIHJ}tyg8zB&OI2`UGEc@V2R9V^B zbST|X3u}1~EYa^b&S+9BgP3MHE!8=^57i}N8W*?q9v3&m15z{a>uQZ9bPPo^3V85Fi{Z)I Jf1e(j@ek$-)^q>> literal 0 HcmV?d00001 diff --git a/docs/verification/images/codex-binding/before-disconnected.png b/docs/verification/images/codex-binding/before-disconnected.png new file mode 100644 index 0000000000000000000000000000000000000000..f838a368477cd078e82c40126fb92b82cf3317f6 GIT binary patch literal 17350 zcmch<1yq#l`ZvrL5d;wfP(pCaR-`2cq(cD(X@(A^yE_F1l?EwkX@-)L9#mA2?jBHS z1_tSd?;7_x-#Op;*8h$5{@3eTE}h}w;h8(G>sL3gloe&kNoh$52nfg@KDe(+KtPyC zK=8){l2hSM-d-Xy>Vd?UFJHcUcZU!jrPX?Lbo9-e zHt^0R+xYgmydMz(fy8n4eY$JcQq$7>wwJXV!(N7ksdH#JY7FZ9{wXayyu5Ly$N&We z1@d8KJ-w9q{xfh>33SeP)Gq1*ol22b*{@#?rn>m69ZqdTkG3WzUR7m>SM>MSkbm-v z+_-UrjjbJC6VOZH-{4_q51UfQzb01kIhuBNqJjTDCA?_z2LZwN2dA~+)m>G;bLF$` z2}+8J5s{GsPYa(JpCfpRC7>ETwEhx8b+o_5yZ&I3^BMucb=l~zgDQm@#Xp)~O!*zG zNQY1gdTlOz4X0~~xaN(U5F`vJ)~j{GP1LS7?xgLNo3_V)9jtu(F>rqiYyJJ_mr(QW zG)%R9I(6fd=3wiJ%?s;ecJLTyo>bou_TAq!@lO)4#|f_;?MyXU^k!aWR@_|dpD(G$ z(ejwI7O3Sh`0YL+-0NuY+-QsE7=QPqIl;hdaoEuRXy&yNgXhde7?clZm(Ps8y3D*E zp;zZp5b^{)EgMc-<+HcmbhKihEMSjz_z~!5wJqp8w@`fPrrhnztD_a>2iq%ZxsOyD zhf-#TbnORE6H_qgHF~*rr-@<|s5tdJ4tLkAB)2|kSD3}IBITIc(>;EkbxwkZ zLNP~2PY%vX1(Al+3E1`LDkQmo4WrSilqSA(`SReystW&;yE<*KVdQmSI8=E4T zt`tEvj>xOrQqzdgHGg{?@!-ni0@adEF^irI_y~m&b`hI;byHO}>z&rzVm)(P`uVGF zWPJ7+FqZ^(V=>q3-B%4}rAwpj8`gqXV_~{WRdYgUbbVReKDV^EFZSiQuTHixl8VlG zkl*gEvcu@)e5B^k@!*tNTicsY*YxXKS)~x$y)GhhI8tUT_bo&ZVLhatg539x0!~OF|2S_aj$j4&P7D&n`7A z4bhGkYI0SHIL$n0ob}cf&$pwi%_&yTeMCbf)*bCWn9irb&v+AFH&?%5dKO^`y~f?aLK++=S<_zB`Ko>*q)AEZXHW-#

WDS?cX2!aX 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 !extracted.images.some((admitted) => admitted.path === image.path))) throw Object.assign(new Error("An attached image is unavailable. Upload it again before sending."), { status: 422 }); + for (const file of attachments.files) { + try { + const path = resolve(file.path); + const stat = lstatSync(path); + if (!isPrivateAttachmentPath(path) || dirname(path) !== resolve(ATTACHMENTS_DIR) || dirname(realpathSync(path)) !== realpathSync(ATTACHMENTS_DIR) || !stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > FILE_MAX_BYTES || !extensionForFileMime(mimeFor(path))) throw new Error("Unavailable attachment"); + } catch { throw Object.assign(new Error("An attached file is unavailable or unsupported. Upload it again before sending."), { status: 422 }); } + } + return [ + ...(extracted.text ? [{ type: "text", text: extracted.text, text_elements: [] }] : []), + ...extracted.images.map(({ path }) => ({ type: "localImage", path })), + ]; +} + +export async function codexItemAttachments(item: any, cwd: string): Promise<{ attachments: NonNullable; unavailable: boolean }> { + const attachments: NonNullable = []; + let unavailable = false; + if (item.type === "imageGeneration" && typeof item.result === "string" && item.result && item.result !== "[redacted]") { + try { + const decoded = decodeGeneratedImage(item.result); + const saved = saveImage(decoded.bytes, decoded.mime); + attachments.push({ kind: "image", path: saved.path, mime: saved.mime }); + return { attachments, unavailable }; + } catch { unavailable = true; } + } + const paths: string[] = item.type === "imageView" && typeof item.path === "string" ? [item.path] + : item.type === "imageGeneration" && typeof item.savedPath === "string" ? [item.savedPath] + : item.type === "agentMessage" && typeof item.text === "string" ? messageFileTargets(item.text.replace(/sandbox:(\/)/g, "$1")) : []; + for (const path of paths.slice(0, 20)) { + let opened: Awaited> | undefined; + try { + opened = await openMessageFile(path, [cwd, ATTACHMENTS_DIR]); + const bytes = await opened.handle.readFile(); + if (opened.mime.startsWith("image/")) { + if (bytes.length > IMAGE_MAX_BYTES) throw new Error("Image too large"); + const decoded = decodeGeneratedImage(bytes.toString("base64")); + const saved = saveImage(decoded.bytes, decoded.mime); + attachments.push({ kind: "image", path: saved.path, mime: saved.mime, name: opened.name }); + } else { + const saved = await saveFile((async function* () { yield bytes; })(), opened.name, opened.mime, { expectedBytes: bytes.length }); + attachments.push({ kind: "file", path: saved.path, mime: saved.mime, name: saved.name }); + } + } catch { unavailable = true; } + finally { await opened?.handle.close(); } + } + return { attachments, unavailable: unavailable || paths.length > 20 }; +} diff --git a/server/codex-binding.e2e.test.ts b/server/codex-binding.e2e.test.ts new file mode 100644 index 00000000000..9115679089c --- /dev/null +++ b/server/codex-binding.e2e.test.ts @@ -0,0 +1,67 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, it } from "vitest"; +import { launchVerificationServer } from "../scripts/control-omb.ts"; +import { openSse } from "./testing/sse.ts"; + +it("relays through the real isolated server routes, SSE, approval cards and inline attachment URLs", async () => { + const fixture = await launchVerificationServer(process.env, undefined, undefined, undefined, undefined, undefined, ["codex"]); + const { url, dataDir, logPath } = fixture.info; + const events = await openSse(`${url}/api/events`); + const api = async (method: string, path: string, body?: unknown, expected = 200) => { + const response = await fetch(`${url}${path}`, { method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(60_000) }); + const result = await response.json() as any; + expect(response.status, `${JSON.stringify(result)}; fixture log: ${logPath}`).toBe(expected); + return result; + }; + try { + const wrapper = join(dataDir, "native-codex-fixture.mjs"); + writeFileSync(wrapper, [ + `#!${process.execPath}`, + `process.env.FAKE_CODEX_NATIVE_DIR = ${JSON.stringify(dataDir)};`, + `await import(${JSON.stringify(pathToFileURL(fileURLToPath(new URL("./testing/fake-codex-native.ts", import.meta.url))).href)});`, + ].join("\n"), { mode: 0o700 }); + await api("PATCH", "/api/instances/codex", { cli: wrapper }); + const bot = (await api("POST", "/api/bots", { name: "Native Codex relay fixture" }, 201)).bot; + const path = `/api/bots/${bot.id}/threads/${bot.threadId}/codex`; + const list = await api("GET", "/api/codex/conversations?instanceId=codex"); + expect(list.conversations).toEqual([{ id: "fixture-native-existing", title: "Native fixture conversation", cwd: dataDir }]); + const connected = await api("PUT", path, { instanceId: "codex", conversationId: "fixture-native-existing" }); + expect(connected).toMatchObject({ connected: true, state: "ready", settings: { model: "fixture-native-model", reasoningEffort: "high", approvalPolicy: "on-request", sandbox: { type: "readOnly" } } }); + const messages = (await api("GET", `/api/threads/${bot.threadId}/messages?limit=100`)).messages; + const image = messages.find((message: any) => message.codex?.itemId === "existing-fixture-image"); + expect(image.attachments[0]).toMatchObject({ kind: "image", mime: "image/png", path: expect.stringMatching(/\.png$/) }); + const downloaded = await fetch(`${url}/api/attachments/${basename(image.attachments[0].path)}`); + expect(downloaded.headers.get("content-type")).toContain("image/png"); + expect((await downloaded.arrayBuffer()).byteLength).toBeGreaterThan(0); + const text = " harmless exact text 😀\n "; + const receipt = await api("POST", `/api/bots/${bot.id}/messages`, { threadId: bot.threadId, text, sendId: "native-fixture-send-001" }, 202); + expect(receipt.message.text).toBe(text); + const reply = await events.until((event) => event.kind === "message" && event.threadId === bot.threadId && event.message?.text === `Native fixture reply: ${text}`); + expect(reply.message.codex.conversationId).toBe("fixture-native-existing"); + const retried = await api("POST", `/api/bots/${bot.id}/messages`, { threadId: bot.threadId, text, sendId: "native-fixture-send-001" }, 202); + expect(retried.message.id).toBe(receipt.message.id); + await api("POST", `/api/bots/${bot.id}/messages`, { threadId: bot.threadId, text: "approval fixture", sendId: "native-fixture-send-002" }, 202); + const ask = await events.until((event) => event.kind === "message" && event.threadId === bot.threadId && event.message?.card?.tool === "Codex"); + expect((await api("GET", path)).state).toBe("awaiting-approval"); + await api("POST", `/api/threads/${bot.threadId}/respond`, { requestId: ask.message.card.requestId, behavior: "allow", always: true }, 400); + await api("POST", `/api/threads/${bot.threadId}/respond`, { requestId: ask.message.card.requestId, behavior: "allow" }); + await events.until((event) => event.kind === "message" && event.threadId === bot.threadId && event.message?.text === "Native fixture reply: approval fixture"); + await api("POST", `/api/bots/${bot.id}/compact`, { threadId: bot.threadId }, 409); + await api("DELETE", path); + expect((await api("GET", path)).configured).toBe(false); + const calls = readFileSync(join(dataDir, "native-fixture-requests.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); + expect(calls.filter((call) => call.method === "turn/start")).toHaveLength(2); + expect(calls.some((call) => call.method === "thread/start" || call.method === "thread/fork")).toBe(false); + expect(calls.find((call) => call.reply)?.reply.result).toEqual({ decision: "accept" }); + await api("PATCH", "/api/instances/codex", { cli: fileURLToPath(new URL("./testing/fake-codex-app-server.ts", import.meta.url)) }); + const incompatible = await api("PUT", path, { instanceId: "codex", conversationId: "fixture-native-existing" }, 409); + expect(incompatible).toMatchObject({ configured: true, connected: false, state: "failed" }); + await api("POST", `/api/bots/${bot.id}/messages`, { threadId: bot.threadId, text: "must not get a fallback answer", sendId: "native-fixture-send-003" }, 409); + const failedMessages = (await api("GET", `/api/threads/${bot.threadId}/messages?limit=100`)).messages; + expect(failedMessages.some((message: any) => message.text === "must not get a fallback answer")).toBe(false); + await api("DELETE", path); + console.log(`Native Codex fixture verification passed; log: ${logPath}`); + } finally { events.close(); await fixture.close(); } +}, 120_000); diff --git a/server/codex-bindings.test.ts b/server/codex-bindings.test.ts new file mode 100644 index 00000000000..4a804880e02 --- /dev/null +++ b/server/codex-bindings.test.ts @@ -0,0 +1,344 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DATA_DIR } from "./config.ts"; +import { Store } from "./store.ts"; +import { CodexBindings } from "./codex-bindings.ts"; +import { CodexNativeRejection, type CodexNativeClient } from "./drivers/codex-native-client.ts"; +import { saveFile, saveImage } from "./attachments.ts"; +import { closeMessageDb } from "./message-db.ts"; + +const CONVERSATION = "fixture-existing-conversation"; +const SEND = "fixture-send-0123456789"; +const SECOND = "fixture-send-abcdefghij"; +const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9xkAAAAASUVORK5CYII=", "base64"); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +class Native implements CodexNativeClient { + verifySavedPermissions = async () => {}; + home = "/fixture/codex-home"; + email = "owner@example.test"; + calls: Array<{ method: string; params: any }> = []; + replies: unknown[] = []; + rejected: unknown[] = []; + turns: any[] = []; + cwd = DATA_DIR; + model = "fixture-saved-model"; + effort = "high"; + approvalPolicy = "on-request"; + sandbox = { type: "readOnly" }; + nativeId = CONVERSATION; + readId = CONVERSATION; + readStatus = "idle"; + historyError = false; + startError: Error | undefined; + startGate: ReturnType | undefined; + connectGate: ReturnType | undefined; + listener: (message: any) => void | Promise = () => {}; + closers: Array<() => void> = []; + waiters: Array<{ method: string; count: number; resolve(value: any): void }> = []; + closed = false; + request = async (method: string, params: any): Promise => { + const call = { method, params }; + this.calls.push(call); + for (const waiter of this.waiters) if (waiter.method === method && this.calls.filter((call) => call.method === method).length >= waiter.count) waiter.resolve(call); + if (method === "account/read") return { account: { type: "chatgpt", email: this.email } }; + if (method === "config/read") return { config: { model_provider: "openai" } }; + if (method === "thread/list") return { data: [{ id: CONVERSATION, name: "Existing fixture", cwd: this.cwd }], nextCursor: null }; + if (method === "thread/read") return { thread: { id: this.readId, name: "Existing fixture", model: this.model, reasoningEffort: this.effort, cwd: this.cwd, status: { type: this.readStatus } } }; + if (method === "thread/resume") { + await this.connectGate?.promise; + return { thread: { id: this.nativeId, name: "Existing fixture" }, model: this.model, modelProvider: "openai", reasoningEffort: this.effort, cwd: this.cwd, approvalPolicy: this.approvalPolicy, approvalsReviewer: "user", sandbox: this.sandbox, collaborationMode: null }; + } + if (method === "thread/turns/list") { + if (this.historyError) throw new Error("History unavailable"); + return { data: this.turns, nextCursor: null }; + } + if (method === "turn/start") { + if (this.startError) throw this.startError; + const turn = { id: `native-turn-${this.calls.filter((call) => call.method === method).length}`, status: "inProgress", items: [{ id: `user-${params.clientUserMessageId}`, type: "userMessage", clientId: params.clientUserMessageId, content: params.input }] }; + this.turns.push(turn); + await this.emit("turn/started", { threadId: CONVERSATION, turn }); + await this.startGate?.promise; + return { turn: { ...turn, status: "inProgress" } }; + } + if (method === "turn/interrupt") { await this.finish("interrupted"); return {}; } + throw new Error(`Unexpected method ${method}`); + }; + next(method: string, count = 1): Promise { + const calls = this.calls.filter((call) => call.method === method); + if (calls.length >= count) return Promise.resolve(calls[count - 1]); + return new Promise((resolve) => this.waiters.push({ method, count, resolve })); + } + reply(id: string | number, result: unknown) { this.replies.push({ id, result }); } + reject(id: string | number) { this.rejected.push(id); } + onMessage(listener: (message: any) => void | Promise) { this.listener = listener; } + onClose(listener: () => void) { this.closers.push(listener); } + async close() { if (!this.closed) { this.closed = true; for (const listener of this.closers) listener(); } } + async emit(method: string, params: any, id?: string | number) { await this.listener({ method, params, ...(id === undefined ? {} : { id }) }); } + async finish(status = "completed", items: any[] = [{ id: "actual-reply", type: "agentMessage", text: "Actual native response" }]) { + const turn = this.turns.at(-1)!; + turn.status = status; + turn.items.push(...items); + await this.emit("turn/completed", { threadId: CONVERSATION, turn }); + } +} + +let dir: string; +const bridges: CodexBindings[] = []; +beforeEach(() => { rmSync(DATA_DIR, { recursive: true, force: true }); dir = mkdtempSync(join(tmpdir(), "omb-codex-binding-")); }); +afterEach(async () => { await Promise.all(bridges.splice(0).map((bridge) => bridge.close())); rmSync(dir, { recursive: true, force: true }); }); + +function fixture() { + const store = new Store(() => ({ instanceId: "unrelated-model", model: "must-not-answer" })); + const bot = store.createBot({ name: "Relay fixture" }); + const thread = bot.threadId; + const native = new Native(); + let current = native; + const make = () => { + const bridge = new CodexBindings({ dataDir: dir, store, connect: async () => current, busy: () => false }); + bridges.push(bridge); + return bridge; + }; + const bridge = make(); + const bind = () => bridge.bind(bot.id, thread, "native-codex", CONVERSATION); + return { store, bot, thread, native, bridge, bind, make, use: (client: Native) => { current = client; }, view: () => bridge.view(bot.id, thread), messages: () => store.messagesFor(thread) }; +} + +describe("existing native Codex bindings", () => { + it("resumes only the exact selected conversation, without configuration or prompt injection", async () => { + const h = fixture(); + const view = await h.bind(); + expect(view).toMatchObject({ configured: true, connected: true, state: "ready", settings: { model: h.native.model, reasoningEffort: "high", cwd: DATA_DIR, approvalPolicy: "on-request", sandbox: { type: "readOnly" } } }); + expect(h.native.calls.find((call) => call.method === "thread/resume")?.params).toEqual({ threadId: CONVERSATION, excludeTurns: true }); + const text = " Exact text\n日本語 😀\n "; + await h.bridge.send(h.bot.id, h.thread, text, SEND); + const { params } = await h.native.next("turn/start"); + expect(params).toEqual({ threadId: CONVERSATION, clientUserMessageId: SEND, input: [{ type: "text", text, text_elements: [] }] }); + expect(h.native.calls.some((call) => ["thread/start", "thread/fork"].includes(call.method))).toBe(false); + expect(() => h.bridge.assertUnbound(h.thread)).toThrow(/reserved/); + expect(() => h.bridge.assertUnbound("another-thread")).not.toThrow(); + await h.native.finish(); + expect(h.messages().find((message) => message.text === "Actual native response")?.codex).toEqual({ conversationId: CONVERSATION, turnId: "native-turn-1", itemId: "actual-reply" }); + }); + + it("never claims ready while connecting, or after a different native thread was returned", async () => { + const h = fixture(); h.native.connectGate = deferred(); + const pending = h.bind(); + await h.native.next("thread/resume"); + expect(h.view()).toMatchObject({ configured: true, connected: false, state: "connecting" }); + h.native.nativeId = "different-conversation"; h.native.connectGate.resolve(undefined); + expect(await pending).toMatchObject({ connected: false, state: "failed" }); + await expect(h.bridge.send(h.bot.id, h.thread, "hello", SEND)).rejects.toThrow(/Reconnect/); + expect(h.native.calls.some((call) => call.method === "turn/start")).toBe(false); + expect(() => h.bridge.assertUnbound(h.thread)).toThrow(); + }); + + it("rejects missing, active and foreign conversations and prevents cross-thread reservations", async () => { + const h = fixture(); h.native.readId = "missing"; + expect(await h.bind()).toMatchObject({ state: "failed", connected: false }); + await h.bridge.disconnect(h.bot.id, h.thread); + h.use(new Native()); + const second = h.store.createTask(h.bot.id, "Second")!.threadId; + await h.bridge.bind(h.bot.id, second, "native-codex", CONVERSATION); + await expect(h.bind()).rejects.toThrow(/already reserved/); + await expect(h.bridge.send("another-bot", second, "hello", SEND)).rejects.toThrow(/No such/); + }); + + it("deduplicates concurrent sends and native replies, preserving FIFO delivery", async () => { + const h = fixture(); await h.bind(); + const [a, b] = await Promise.all([h.bridge.send(h.bot.id, h.thread, "first", SEND), h.bridge.send(h.bot.id, h.thread, "first", SEND)]); + expect(a.id).toBe(b.id); + await h.native.next("turn/start"); + await h.bridge.send(h.bot.id, h.thread, "second", SECOND); + expect(h.native.calls.filter((call) => call.method === "turn/start")).toHaveLength(1); + await h.native.finish(); + await h.native.next("turn/start", 2); + await h.native.finish("completed", [{ id: "actual-reply", type: "agentMessage", text: "Second response" }]); + await h.native.emit("item/completed", { threadId: CONVERSATION, turnId: "native-turn-2", item: { id: "actual-reply", type: "agentMessage", text: "Second response" } }); + expect(h.messages().filter((message) => message.text === "Second response")).toHaveLength(1); + expect(h.view().deliveries.map((delivery) => delivery.state)).toEqual(["completed", "completed"]); + await expect(h.bridge.send(h.bot.id, h.thread, "different", SEND)).rejects.toThrow(/sendId/); + }); + + it("handles completion before the turn/start acknowledgement", async () => { + const h = fixture(); await h.bind(); h.native.startGate = deferred(); + await h.bridge.send(h.bot.id, h.thread, "first", SEND); + await h.native.next("turn/start"); + await h.native.finish(); h.native.startGate.resolve(undefined); + await h.bridge.send(h.bot.id, h.thread, "second", SECOND); + await h.native.next("turn/start", 2); + expect(h.view().deliveries[0]?.state).toBe("completed"); + await h.native.finish(); + expect(h.view().running).toBe(false); + }); + + it("persists binding and receipts across restarts, then reconciles native IDs without replay", async () => { + const h = fixture(); await h.bind(); + await h.bridge.send(h.bot.id, h.thread, "first", SEND); await h.native.next("turn/start"); + await h.bridge.close(); + const replacement = new Native(); replacement.turns = structuredClone(h.native.turns); replacement.turns[0].status = "completed"; + replacement.turns[0].items.push({ id: "after-crash", type: "agentMessage", text: "Recovered native response" }); + h.use(replacement); const restarted = h.make(); + expect(restarted.view(h.bot.id, h.thread)).toMatchObject({ configured: true, connected: false, state: "disconnected" }); + expect(await restarted.reconnect(h.bot.id, h.thread)).toMatchObject({ connected: true, deliveries: [{ sendId: SEND, state: "completed" }] }); + const receipt = await restarted.send(h.bot.id, h.thread, "first", SEND); + expect(receipt.id).toBe(h.messages().find((message) => message.sendId === SEND)?.id); + expect(replacement.calls.filter((call) => call.method === "turn/start")).toHaveLength(0); + expect(h.messages().filter((message) => message.text === "Recovered native response")).toHaveLength(1); + const saved = JSON.parse(readFileSync(join(dir, "codex-bindings.json"), "utf8")); + expect(saved.bindings[0].identity).not.toContain("owner@example.test"); + if (process.platform !== "win32") expect(statSync(join(dir, "codex-bindings.json")).mode & 0o777).toBe(0o600); + }); + + it("holds uncertain dispatch and queued successors through recovery until explicit review", async () => { + const h = fixture(); await h.bind(); h.native.startError = new Error("transport lost"); + await h.bridge.send(h.bot.id, h.thread, "may have arrived", SEND); + await h.native.next("turn/start"); await h.native.close(); + const next = new Native(); h.use(next); + await h.bridge.reconnect(h.bot.id, h.thread); + expect(h.view().deliveries[0]?.state).toBe("uncertain"); + await h.bridge.send(h.bot.id, h.thread, "follow-up", SECOND); + expect(next.calls.filter((call) => call.method === "turn/start")).toHaveLength(0); + h.bridge.acknowledge(h.bot.id, h.thread, SEND); + const { params } = await next.next("turn/start"); + expect(params.clientUserMessageId).toBe(SECOND); + expect(h.messages().filter((message) => message.sendId === SEND)).toHaveLength(1); + }); + + it("deduplicates from a newly opened durable message store after a reply/outbox crash window", async () => { + const h = fixture(); await h.bind(); + await h.bridge.send(h.bot.id, h.thread, "durable", SEND); await h.native.next("turn/start"); + const beforeCompletion = readFileSync(join(dir, "codex-bindings.json"), "utf8"); + await h.native.finish(); await h.bridge.close(); closeMessageDb(); + writeFileSync(join(dir, "codex-bindings.json"), beforeCompletion); + const reopened = new Store(() => ({ instanceId: "unrelated", model: "unrelated" })); + const next = new Native(); next.turns = structuredClone(h.native.turns); + const restarted = new CodexBindings({ dataDir: dir, store: reopened, connect: async () => next, busy: () => false }); bridges.push(restarted); + await restarted.reconnect(h.bot.id, h.thread); + const receipt = await restarted.send(h.bot.id, h.thread, "durable", SEND); + expect(receipt.sendId).toBe(SEND); + expect(reopened.messagesFor(h.thread).filter((message) => message.text === "Actual native response")).toHaveLength(1); + expect(next.calls.some((call) => call.method === "turn/start")).toBe(false); + expect(restarted.view(h.bot.id, h.thread).deliveries[0]?.state).toBe("completed"); + }); + + it("prevents alias instances for the same account/store from binding the same native conversation", async () => { + const h = fixture(); await h.bind(); + const other = h.store.createTask(h.bot.id, "Alias")!.threadId; + const next = new Native(); h.use(next); + expect(await h.bridge.bind(h.bot.id, other, "alias-codex", CONVERSATION)).toMatchObject({ connected: false, state: "failed" }); + expect(next.calls.some((call) => call.method === "thread/resume")).toBe(false); + }); + + it("does not repeat a rejected native request, and exposes history failures truthfully", async () => { + const h = fixture(); await h.bind(); h.native.startError = new CodexNativeRejection("Native rejected"); + await h.bridge.send(h.bot.id, h.thread, "rejected", SEND); await h.native.next("turn/start"); + await h.native.close(); + const next = new Native(); next.historyError = true; h.use(next); + expect(await h.bridge.reconnect(h.bot.id, h.thread)).toMatchObject({ connected: false, state: "failed" }); + expect(next.calls.some((call) => call.method === "turn/start")).toBe(false); + }); + + it("refuses changed account, conversation store and saved settings on reconnect", async () => { + const h = fixture(); await h.bind(); await h.native.close(); + for (const change of ["email", "home", "model"] as const) { + const next = new Native(); next[change] = "changed"; h.use(next); + expect(await h.bridge.reconnect(h.bot.id, h.thread)).toMatchObject({ connected: false, state: "failed" }); + expect(next.calls.some((call) => call.method === "turn/start")).toBe(false); + } + }); + + it("requires explicit scoped approvals and expires stale requests", async () => { + const h = fixture(); await h.bind(); await h.bridge.send(h.bot.id, h.thread, "approval", SEND); await h.native.next("turn/start"); + await h.native.emit("item/commandExecution/requestApproval", { threadId: CONVERSATION, turnId: "native-turn-1", itemId: "command-1", command: "pwd", cwd: DATA_DIR, reason: "Read the current folder" }, 45); + expect(h.view().state).toBe("awaiting-approval"); expect(h.native.replies).toHaveLength(0); + const card = h.messages().find((message) => message.card)?.card; + expect(card).toMatchObject({ tool: "Codex", requestType: "permission" }); + expect(card?.allowSession).toBeUndefined(); + await expect(h.bridge.answer("foreign-bot", h.thread, card!.requestId!, "allow")).rejects.toThrow(); + expect(await h.bridge.answer(h.bot.id, h.thread, card!.requestId!, "allow")).toBe("allowed-once"); + expect(h.native.replies).toEqual([{ id: 45, result: { decision: "accept" } }]); + expect(await h.bridge.answer(h.bot.id, h.thread, card!.requestId!, "allow")).toBe("unavailable"); + await h.native.emit("item/fileChange/requestApproval", { threadId: CONVERSATION, turnId: "native-turn-1", itemId: "file-1", grantRoot: DATA_DIR }, 46); + await h.native.close(); + expect(h.messages().find((message) => message.card?.requestId !== card!.requestId && message.card)?.card).toMatchObject({ expired: true, answered: "unavailable" }); + expect(h.native.replies).toHaveLength(1); + }); + + it("fails closed on foreign or unsupported approval methods", async () => { + const h = fixture(); await h.bind(); + await h.native.emit("item/commandExecution/requestApproval", { threadId: "foreign", turnId: "turn", itemId: "item" }, 1); + await h.native.emit("item/permissions/requestApproval", { threadId: CONVERSATION, turnId: "turn", permissions: { fileSystem: { write: ["/"] } } }, 2); + expect(h.native.rejected).toEqual([1, 2]); expect(h.native.replies).toHaveLength(0); + expect(h.messages().some((message) => message.tool?.name.includes("No permission was granted"))).toBe(true); + }); + + it("routes native questions through structured answer cards", async () => { + const h = fixture(); await h.bind(); await h.bridge.send(h.bot.id, h.thread, "question", SEND); await h.native.next("turn/start"); + await h.native.emit("item/tool/requestUserInput", { threadId: CONVERSATION, turnId: "native-turn-1", questions: [{ id: "choice", header: "Pick", question: "Which option?", options: [{ label: "A", description: "First" }, { label: "B", description: "Second" }] }] }, "question-id"); + const card = h.messages().find((message) => message.card)?.card; + expect(card?.questionRequest?.questions[0]?.question).toBe("Which option?"); + await h.bridge.answer(h.bot.id, h.thread, card!.requestId!, "answer", "A"); + expect(h.native.replies).toEqual([{ id: "question-id", result: { answers: { choice: { answers: ["A"] } } } }]); + }); + + it("forwards admitted images and imports actual generated and existing images inline once", async () => { + const h = fixture(); const image = saveImage(PNG, "image/png"); + h.native.turns = [{ id: "old-native-turn", status: "completed", items: [{ id: "existing-image", type: "imageView", path: image.path }] }]; + await h.bind(); + expect(h.messages().find((message) => message.codex?.itemId === "existing-image")?.attachments?.[0]).toMatchObject({ kind: "image", mime: "image/png" }); + await h.bridge.send(h.bot.id, h.thread, `Look\n\n`, SEND); + const { params } = await h.native.next("turn/start"); + expect(params.input).toEqual([{ type: "text", text: "Look\n\n", text_elements: [] }, { type: "localImage", path: image.path }]); + await h.native.finish("completed", [{ id: "generated", type: "imageGeneration", result: PNG.toString("base64") }]); + const generated = h.messages().find((message) => message.codex?.itemId === "generated")!; + expect(generated.attachments?.[0]).toMatchObject({ kind: "image", mime: "image/png" }); + expect(readFileSync(generated.attachments![0]!.path)).toEqual(PNG); + await h.native.close(); h.use(new Native()); await h.bridge.reconnect(h.bot.id, h.thread); + expect(h.messages().filter((message) => message.codex?.itemId === "existing-image")).toHaveLength(1); + }); + + it("reports inaccessible files and rejects an unavailable user attachment before dispatch", async () => { + const h = fixture(); h.native.turns = [{ id: "old", status: "completed", items: [{ id: "missing-image", type: "imageView", path: join(dir, "missing.png") }] }]; + await h.bind(); + expect(h.messages().some((message) => message.tool?.name.includes("could not be accessed"))).toBe(true); + await expect(h.bridge.send(h.bot.id, h.thread, '', SEND)).rejects.toThrow(/unavailable/); + expect(h.native.calls.some((call) => call.method === "turn/start")).toBe(false); + }); + + it("preserves attachment syntax examples and forwards admitted document references without changing permissions", async () => { + const h = fixture(); await h.bind(); + const document = await saveFile((async function* () { yield Buffer.from("Harmless document"); })(), "fixture.txt", "text/plain"); + const text = `Read this if your native permissions allow it.\n\n\n\nExample: \`\``; + await h.bridge.send(h.bot.id, h.thread, text, SEND); + expect((await h.native.next("turn/start")).params.input).toEqual([{ type: "text", text, text_elements: [] }]); + await h.native.finish("completed", [{ id: "document-reply", type: "agentMessage", text: `[Existing document](${document.path})` }]); + expect(h.messages().find((message) => message.codex?.itemId === "document-reply")?.attachments?.[0]).toMatchObject({ kind: "file", mime: "text/plain", name: expect.stringMatching(/\.txt$/) }); + }); + + it("cancels queued and running turns without discarding receipts", async () => { + const h = fixture(); await h.bind(); await h.bridge.send(h.bot.id, h.thread, "first", SEND); await h.native.next("turn/start"); + await h.bridge.send(h.bot.id, h.thread, "second", SECOND); + await h.bridge.cancel(h.bot.id, h.thread, SECOND); + await h.bridge.cancel(h.bot.id, h.thread); + expect(h.view().deliveries.map((delivery) => delivery.state)).toEqual(["interrupted", "cancelled"]); + await h.bridge.disconnect(h.bot.id, h.thread); + expect(h.view()).toMatchObject({ configured: false, state: "disconnected" }); + const next = new Native(); h.use(next); await h.bind(); + await h.bridge.send(h.bot.id, h.thread, "first", SEND); + expect(next.calls.some((call) => call.method === "turn/start")).toBe(false); + }); + + it("fails startup closed if the durable binding ledger is corrupted", () => { + writeFileSync(join(dir, "codex-bindings.json"), "{broken"); + expect(() => fixture()).toThrow(/could not be read safely/); + }); +}); diff --git a/server/codex-bindings.ts b/server/codex-bindings.ts new file mode 100644 index 00000000000..8556221ef41 --- /dev/null +++ b/server/codex-bindings.ts @@ -0,0 +1,442 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CodexBindingView, CodexConnectionState, CodexDeliveryState } from "../shared/codex-binding.ts"; +import { parseProtocolAskQuestions } from "../shared/ask-question.ts"; +import { writeFileAtomic } from "./atomic.ts"; +import { codexItemAttachments, codexUserInput } from "./codex-binding-attachments.ts"; +import { codexQuestionReply } from "./drivers/codex-questions.ts"; +import { CodexNativeRejection, type CodexNativeClient } from "./drivers/codex-native-client.ts"; +import type { Message, Store } from "./store.ts"; + +interface Delivery { + sendId: string; + text: string; + state: CodexDeliveryState; + turnId?: string; +} + +interface Binding { + botId: string; + threadId: string; + instanceId: string; + conversationId: string; + title?: string; + identity?: string; + settings?: NonNullable & { modelProvider: string; approvalsReviewer: unknown; collaborationMode: unknown }; + deliveries: Delivery[]; +} + +interface Connection { + state: CodexConnectionState; + client?: CodexNativeClient; + error?: string; + running?: string; + work: Promise; + asks: Map; + pumping: boolean; + terminalTurns: Set; +} + +export interface CodexBindingsDeps { + dataDir: string; + store: Pick; + connect(instanceId: string): Promise; + busy(botId: string, threadId: string): boolean; +} + +function refuse(message: string, status = 409): never { throw Object.assign(new Error(message), { status }); } +function same(a: unknown, b: unknown): boolean { + const stable = (value: any): any => Array.isArray(value) ? value.map(stable) + : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) : value; + return JSON.stringify(stable(a)) === JSON.stringify(stable(b)); +} + +/** Private, single-server outbox. A dispatch with no native acknowledgement is never automatically replayed. */ +export class CodexBindings { + private readonly deps: CodexBindingsDeps; + private readonly bindings = new Map(); + private readonly connections = new Map(); + private readonly file: string; + private stopped = false; + + constructor(deps: CodexBindingsDeps) { + this.deps = deps; + this.file = join(deps.dataDir, "codex-bindings.json"); + try { + const data = JSON.parse(readFileSync(this.file, "utf8")); + if (data.version !== 1 || !Array.isArray(data.bindings)) throw new Error("Unsupported Codex binding data"); + for (const binding of data.bindings as Binding[]) { + if (!binding.threadId || !binding.botId || !binding.instanceId || !binding.conversationId || !Array.isArray(binding.deliveries) || this.bindings.has(binding.threadId)) throw new Error("Invalid Codex binding data"); + for (const delivery of binding.deliveries) { + if (delivery.state === "dispatching" || delivery.state === "delivered") delivery.state = "uncertain"; + } + this.bindings.set(binding.threadId, binding); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("Codex binding data could not be read safely. Restore it before starting OpenMausBot."); + } + } + + private persist(): void { + mkdirSync(this.deps.dataDir, { recursive: true, mode: 0o700 }); + writeFileAtomic(this.file, JSON.stringify({ version: 1, bindings: [...this.bindings.values()] }), { mode: 0o600 }); + } + + configured(threadId: string): boolean { return this.bindings.has(threadId); } + + assertUnbound(threadId: string): void { + if (this.configured(threadId)) refuse("This thread is reserved for its existing Codex conversation. Use its connection controls or disconnect it first."); + } + + private owned(botId: string, threadId: string): Binding { + const binding = this.bindings.get(threadId); + if (!binding || binding.botId !== botId || !this.deps.store.taskByThread(botId, threadId)) refuse("No such Codex connection.", 404); + return binding; + } + + view(botId: string, threadId: string): CodexBindingView { + if (!this.deps.store.taskByThread(botId, threadId)) refuse("No such thread.", 404); + const binding = this.bindings.get(threadId); + if (!binding) return { configured: false, connected: false, state: "disconnected", deliveries: [] }; + this.owned(botId, threadId); + const connection = this.connections.get(threadId); + const state = connection?.state ?? "disconnected"; + return { + configured: true, connected: state === "ready" || state === "awaiting-approval", state, + instanceId: binding.instanceId, conversationId: binding.conversationId, title: binding.title, + settings: binding.settings && { model: binding.settings.model, reasoningEffort: binding.settings.reasoningEffort, cwd: binding.settings.cwd, approvalPolicy: binding.settings.approvalPolicy, sandbox: binding.settings.sandbox }, error: connection?.error, running: Boolean(connection?.running), + deliveries: binding.deliveries.map(({ sendId, state, turnId }) => ({ sendId, state, turnId })), + }; + } + + async list(instanceId: string, searchTerm = "", cursor?: string): Promise { + const client = await this.deps.connect(instanceId); + try { + await this.identity(client); + const result = await client.request("thread/list", { limit: 30, searchTerm, ...(cursor ? { cursor } : {}) }); + if (!Array.isArray(result?.data)) throw new Error("Codex returned an unsupported conversation list."); + return { conversations: result.data.map((thread: any) => ({ id: thread.id, title: thread.name ?? thread.preview ?? thread.id, cwd: thread.cwd })), nextCursor: result.nextCursor ?? null }; + } finally { await client.close(); } + } + + private async identity(client: CodexNativeClient): Promise { + const result = await client.request("account/read", { refreshToken: false }); + if (result?.account?.type !== "chatgpt" || typeof result.account.email !== "string") throw new Error("Sign in to Codex with the ChatGPT account that owns the conversation. API-key accounts are not supported by this connection."); + const initialized = await client.request("config/read", { includeLayers: false }); + if (typeof initialized?.config?.model_provider === "string" && initialized.config.model_provider !== "openai") throw new Error("An existing conversation connection requires Codex's native OpenAI provider."); + return createHash("sha256").update(JSON.stringify([client.home, result.account.email.toLowerCase()])).digest("hex"); + } + + async bind(botId: string, threadId: string, instanceId: string, conversationId: string): Promise { + if (this.stopped) refuse("OpenMausBot is stopping."); + if (!this.deps.store.taskByThread(botId, threadId)) refuse("No such thread.", 404); + if (!/^[\w-]{1,128}$/.test(conversationId)) refuse("Select an exact Codex conversation ID.", 400); + if (this.bindings.has(threadId)) refuse("Disconnect the current Codex conversation before selecting another."); + if (this.deps.busy(botId, threadId)) refuse("Stop this thread's current turn before connecting Codex."); + if ([...this.bindings.values()].some((binding) => binding.instanceId === instanceId && binding.conversationId === conversationId)) refuse("That Codex conversation is already reserved by another thread."); + const binding: Binding = { botId, threadId, instanceId, conversationId, deliveries: [] }; + this.bindings.set(threadId, binding); + try { this.persist(); } catch (error) { this.bindings.delete(threadId); throw error; } + return this.reconnect(botId, threadId); + } + + async reconnect(botId: string, threadId: string): Promise { + const binding = this.owned(botId, threadId); + const previous = this.connections.get(threadId); + if (previous?.state === "connecting" || previous?.client) refuse("The Codex connection is already open or connecting."); + const connection: Connection = { state: "connecting", work: Promise.resolve(), asks: new Map(), pumping: false, terminalTurns: new Set() }; + this.connections.set(threadId, connection); + try { + const client = await this.deps.connect(binding.instanceId); + connection.client = client; + client.onClose(() => this.closed(binding, connection)); + const identity = await this.identity(client); + if (binding.identity && binding.identity !== identity) throw new Error("The signed-in Codex account changed. Restore the original account before reconnecting."); + if ([...this.bindings.values()].some((other) => other !== binding && other.identity === identity && other.conversationId === binding.conversationId)) throw new Error("This Codex conversation is already reserved through another instance."); + binding.identity = identity; + this.persist(); + const read = await client.request("thread/read", { threadId: binding.conversationId }); + if (read?.thread?.id !== binding.conversationId) throw new Error("Codex did not return the selected existing conversation."); + if (read.thread.status?.type === "active") throw new Error("This Codex conversation is already running. Finish or stop it in Codex before reconnecting."); + await client.verifySavedPermissions(read.thread); + const resumed = await client.request("thread/resume", { threadId: binding.conversationId, excludeTurns: true }); + if (resumed?.thread?.id !== binding.conversationId) throw new Error("Codex resumed a different conversation. No message was sent."); + const settings = this.settings(resumed); + if (typeof read.thread.model !== "string" || read.thread.model !== settings.model || read.thread.cwd !== settings.cwd || read.thread.reasoningEffort !== settings.reasoningEffort) throw new Error("Codex could not preserve the selected conversation's saved model, effort, or working folder. No message was sent."); + if (binding.settings && !same(binding.settings, settings)) throw new Error("Codex's saved settings changed. No message was sent. Review them in Codex, then disconnect and select the conversation again to accept its current settings."); + binding.identity = identity; + binding.title = resumed.thread.name ?? read.thread.name ?? binding.conversationId; + binding.settings = settings; + this.persist(); + client.onMessage((message) => { + connection.work = connection.work.then(() => this.receive(binding, connection, message)).catch(() => { + connection.state = "failed"; + connection.error = "A Codex response could not be saved. Reconnect to recover it from native history."; + void client.close().catch(() => {}); + }); + return connection.work; + }); + await this.reconcile(binding, connection); + if (this.stopped || this.bindings.get(threadId) !== binding || connection.state !== "connecting") throw new Error("The Codex connection closed before it was ready."); + connection.state = "ready"; + void this.pump(binding, connection); + } catch (error) { + connection.state = "failed"; + connection.error = error instanceof Error ? error.message : "Codex could not connect."; + const client = connection.client; + connection.client = undefined; + await client?.close().catch(() => {}); + } + return this.view(botId, threadId); + } + + private settings(result: any): NonNullable { + if (typeof result.model !== "string" || typeof result.cwd !== "string" || !result.sandbox || result.approvalPolicy === undefined || !(result.reasoningEffort === null || typeof result.reasoningEffort === "string")) throw new Error("Codex did not report its effective saved settings. No message was sent."); + return { model: result.model, modelProvider: result.modelProvider, reasoningEffort: result.reasoningEffort, cwd: result.cwd, sandbox: result.sandbox, approvalPolicy: result.approvalPolicy, approvalsReviewer: result.approvalsReviewer, collaborationMode: result.collaborationMode }; + } + + private notice(binding: Binding, itemId: string, text: string, turnId = "connection"): void { + const existing = this.deps.store.messagesFor(binding.threadId).some((message) => message.codex?.conversationId === binding.conversationId && message.codex.itemId === itemId && message.codex.turnId === turnId); + if (!existing) this.deps.store.appendMessage(binding.threadId, { role: "bot", kind: "activity", tool: { name: text, ok: false }, codex: { conversationId: binding.conversationId, turnId, itemId } }); + } + + private closed(binding: Binding, connection: Connection): void { + connection.client = undefined; + if (connection.state !== "failed") connection.state = "disconnected"; + for (const delivery of binding.deliveries) if (delivery.state === "dispatching" || delivery.state === "delivered") delivery.state = "uncertain"; + for (const ask of connection.asks.values()) this.expire(binding, ask.messageId); + connection.asks.clear(); + connection.running = undefined; + this.persist(); + } + + private expire(binding: Binding, messageId: string): void { + const message = this.deps.store.messagesFor(binding.threadId).find((message) => message.id === messageId); + if (message?.card && !message.card.answered) this.deps.store.patchMessage(binding.threadId, message.id, { card: { ...message.card, answered: "unavailable", expired: true } }); + } + + private async reconcile(binding: Binding, connection: Connection): Promise { + const client = connection.client!; + let cursor: string | undefined; + const cursors = new Set(); + const turns: any[] = []; + do { + const page = await client.request("thread/turns/list", { threadId: binding.conversationId, limit: 100, sortDirection: "asc", itemsView: "full", ...(cursor ? { cursor } : {}) }); + if (!Array.isArray(page?.data)) throw new Error("Codex history is unavailable. No message was sent."); + turns.push(...page.data); + cursor = page.nextCursor ?? undefined; + if (cursor && cursors.has(cursor) || turns.length > 10_000) throw new Error("Codex history could not be reconciled safely."); + if (cursor) cursors.add(cursor); + } while (cursor); + for (const turn of turns) { + if (!Array.isArray(turn.items) || typeof turn.id !== "string") throw new Error("Codex returned unsupported history."); + for (const item of turn.items) { + if (item.type === "userMessage" && item.clientId) { + const delivery = binding.deliveries.find((delivery) => delivery.sendId === item.clientId); + if (delivery) { delivery.turnId = turn.id; delivery.state = this.terminal(turn.status) ?? "uncertain"; } + } + await this.item(binding, turn.id, item); + } + if (turn.status === "inProgress") connection.running = turn.id; + } + for (const message of this.deps.store.messagesFor(binding.threadId)) if (message.codex?.conversationId === binding.conversationId && message.card && !message.card.answered) this.expire(binding, message.id); + this.persist(); + } + + private terminal(status: string): CodexDeliveryState | undefined { + return status === "completed" ? "completed" : status === "interrupted" ? "interrupted" : status === "failed" ? "failed" : undefined; + } + + private async item(binding: Binding, turnId: string, item: any): Promise { + if (typeof item?.id !== "string" || !["agentMessage", "imageGeneration", "imageView"].includes(item.type)) return; + if (this.deps.store.messagesFor(binding.threadId).some((message) => message.codex?.conversationId === binding.conversationId && message.codex.turnId === turnId && message.codex.itemId === item.id)) return; + const { attachments, unavailable } = await codexItemAttachments(item, binding.settings!.cwd); + const text = item.type === "agentMessage" && typeof item.text === "string" ? item.text : undefined; + if (text || attachments.length) this.deps.store.appendMessage(binding.threadId, { + role: "bot", kind: "text", text, ...(attachments.length ? { attachments } : {}), + codex: { conversationId: binding.conversationId, turnId, itemId: item.id }, + }); + if (unavailable) this.notice(binding, `${item.id}:unavailable`, "A Codex attachment could not be accessed or is unsupported. Upload it here from its original location.", turnId); + } + + async send(botId: string, threadId: string, text: string, sendId: string): Promise { + const binding = this.owned(botId, threadId); + if (!/^[A-Za-z0-9_-]{16,80}$/.test(sendId)) refuse("A stable sendId is required for Codex delivery.", 400); + if (!text.trim() || text.length > 1_000_000) refuse("Enter a message of at most 1,000,000 characters.", 400); + const previous = binding.deliveries.find((delivery) => delivery.sendId === sendId); + if (previous && previous.text !== text) refuse("sendId already belongs to another message."); + const accepted = this.deps.store.messagesFor(threadId).find((message) => message.role === "user" && message.sendId === sendId); + if (accepted && !previous) { + if (accepted.text !== text || accepted.codex?.conversationId !== binding.conversationId) refuse("sendId already belongs to another message."); + return accepted; + } + if (!previous) { + const connection = this.connections.get(threadId); + if (!connection?.client || !["ready", "awaiting-approval"].includes(connection.state)) refuse("Reconnect Codex before sending. This message has not been delivered."); + codexUserInput(text); + if (binding.deliveries.filter((delivery) => delivery.state === "queued").length >= 20) refuse("The Codex message queue is full.", 429); + binding.deliveries.push({ sendId, text, state: "queued" }); + this.persist(); + } + let message = this.deps.store.messagesFor(threadId).find((message) => message.role === "user" && message.sendId === sendId); + if (!message) message = this.deps.store.appendMessage(threadId, { role: "user", kind: "text", text, sendId, codex: { conversationId: binding.conversationId, turnId: previous?.turnId ?? "pending", itemId: sendId } }); + const connection = this.connections.get(threadId); + if (connection) void this.pump(binding, connection); + return message; + } + + private async pump(binding: Binding, connection: Connection): Promise { + if (connection.pumping || connection.running || connection.state !== "ready" || !connection.client || this.stopped) return; + if (binding.deliveries.some((delivery) => delivery.state === "uncertain")) return; + const delivery = binding.deliveries.find((delivery) => delivery.state === "queued"); + if (!delivery) return; + connection.pumping = true; + try { + const client = connection.client; + if (await this.identity(client) !== binding.identity) throw new Error("The signed-in Codex account changed. Restore the original account before reconnecting."); + const input = codexUserInput(delivery.text); + if (connection.client !== client || connection.state !== "ready" || this.stopped) return; + delivery.state = "dispatching"; + this.persist(); + const result = await client.request("turn/start", { threadId: binding.conversationId, clientUserMessageId: delivery.sendId, input }); + if (typeof result?.turn?.id !== "string") throw new Error("Codex did not acknowledge a native turn."); + delivery.turnId = result.turn.id; + if (delivery.state === "dispatching") delivery.state = this.terminal(result.turn.status) ?? "delivered"; + if (!this.terminal(result.turn.status) && !connection.terminalTurns.has(result.turn.id)) connection.running = result.turn.id; + this.persist(); + } catch (error) { + if (delivery.state === "dispatching") delivery.state = error instanceof CodexNativeRejection ? "failed" : "uncertain"; + connection.state = "failed"; + connection.error = error instanceof Error ? error.message : "Codex delivery failed."; + this.persist(); + this.notice(binding, `delivery:${delivery.sendId}`, delivery.state === "uncertain" ? "Codex delivery is uncertain. Reconnect and review the native conversation before sending it again." : "Codex did not accept this message. Reconnect before trying again."); + const client = connection.client; + connection.client = undefined; + await client?.close().catch(() => {}); + } finally { + connection.pumping = false; + if (!connection.running) void this.pump(binding, connection); + } + } + + private async receive(binding: Binding, connection: Connection, message: any): Promise { + if (this.bindings.get(binding.threadId) !== binding) return; + const params = message.params; + if (params?.threadId !== binding.conversationId) { + if (message.id !== undefined) connection.client?.reject(message.id); + return; + } + if (message.id !== undefined) { this.ask(binding, connection, message); return; } + if (message.method === "item/completed" && typeof params.turnId === "string") await this.item(binding, params.turnId, params.item); + if (message.method === "turn/started" && typeof params.turn?.id === "string") connection.running = params.turn.id; + if (message.method === "serverRequest/resolved") { + for (const [id, ask] of connection.asks) if (ask.id === params.requestId) { this.expire(binding, ask.messageId); connection.asks.delete(id); } + if (!connection.asks.size && connection.state === "awaiting-approval") connection.state = "ready"; + } + if (message.method === "turn/completed" && typeof params.turn?.id === "string") { + connection.terminalTurns.add(params.turn.id); + for (const item of params.turn.items ?? []) await this.item(binding, params.turn.id, item); + for (const delivery of binding.deliveries) if (delivery.turnId === params.turn.id || delivery.state === "dispatching" && !delivery.turnId) { + delivery.turnId = params.turn.id; + delivery.state = this.terminal(params.turn.status) ?? "uncertain"; + } + if (connection.running === params.turn.id) connection.running = undefined; + for (const [id, ask] of connection.asks) if (ask.params.turnId === params.turn.id) { this.expire(binding, ask.messageId); connection.asks.delete(id); } + if (!connection.asks.size && connection.state === "awaiting-approval") connection.state = "ready"; + this.persist(); + void this.pump(binding, connection); + } + if (message.method === "thread/settings/updated") { + connection.state = "failed"; + connection.error = "Codex settings changed while connected. Reconnect and review the conversation before sending again."; + await connection.client?.close(); + } + if (message.method === "thread/closed" || message.method === "error" && !params.willRetry) { + connection.state = "failed"; + connection.error = "Codex reported a connection or turn failure. Reconnect to recover native history."; + await connection.client?.close(); + } + } + + private ask(binding: Binding, connection: Connection, message: any): void { + const { method, params, id } = message; + const question = method === "item/tool/requestUserInput" ? parseProtocolAskQuestions(params.questions) : null; + const permission = method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval"; + if ((!permission && !question) || typeof params.turnId !== "string" || params.turnId !== connection.running) { + connection.client?.reject(id); + this.notice(binding, `unsupported:${String(id)}`, "Codex requested an unsupported approval or input interface. No permission was granted. Continue in Codex or cancel this turn."); + return; + } + if ([...connection.asks.values()].some((ask) => ask.id === id)) return; + const requestId = `codex-${randomUUID()}`; + const subtitle = [params.reason, params.command, params.cwd, params.grantRoot && `Write access: ${params.grantRoot}`, params.networkApprovalContext && JSON.stringify(params.networkApprovalContext)].filter((value) => typeof value === "string").join("\n"); + const saved = this.deps.store.appendMessage(binding.threadId, { + role: "bot", kind: "options", codex: { conversationId: binding.conversationId, turnId: params.turnId, itemId: `request:${requestId}` }, + card: { + title: question ? "Codex needs your answer" : "Codex needs your approval", subtitle, requestId, + requestType: question ? "question" : "permission", tool: "Codex", options: question ? [] : ["Allow", "Deny"], + ...(question ? { questionRequest: { version: 1, questions: question.map(({ question }) => question) } } : {}), + }, + }); + connection.asks.set(requestId, { id, method, params, messageId: saved.id }); + connection.state = "awaiting-approval"; + } + + async answer(botId: string, threadId: string, requestId: string, behavior: string, message?: string): Promise { + const binding = this.owned(botId, threadId); + const connection = this.connections.get(threadId); + const ask = connection?.asks.get(requestId); + if (!connection?.client || !ask) return "unavailable"; + if (await this.identity(connection.client) !== binding.identity) refuse("The Codex account changed. No approval was sent.", 403); + if (!connection.asks.has(requestId)) return "unavailable"; + const question = ask.method === "item/tool/requestUserInput" ? parseProtocolAskQuestions(ask.params.questions) : null; + if (question ? !["answer", "deny"].includes(behavior) : !["allow", "deny"].includes(behavior)) refuse("This answer does not match the native request.", 400); + connection.client.reply(ask.id, question ? codexQuestionReply(behavior as "answer" | "deny", message, "user", question) : { decision: behavior === "allow" ? "accept" : "decline" }); + connection.asks.delete(requestId); + const saved = this.deps.store.messagesFor(threadId).find((item) => item.id === ask.messageId); + if (saved?.card) this.deps.store.patchMessage(threadId, saved.id, { card: { ...saved.card, answered: behavior, ...(question && message ? { answeredText: message } : {}) } }); + if (!connection.asks.size) connection.state = "ready"; + return behavior === "allow" ? "allowed-once" : behavior === "answer" ? "answered" : "rejected"; + } + + async cancel(botId: string, threadId: string, sendId?: string): Promise { + const binding = this.owned(botId, threadId); + const connection = this.connections.get(threadId); + if (sendId) { + const delivery = binding.deliveries.find((delivery) => delivery.sendId === sendId); + if (!delivery) refuse("No such Codex delivery.", 404); + if (delivery.state === "queued") { delivery.state = "cancelled"; this.persist(); return; } + if (delivery.turnId !== connection?.running) refuse("Only a queued or currently running delivery can be cancelled."); + } + if (!connection?.client || !connection.running) refuse("No connected native turn is running. Reconnect and inspect Codex before retrying."); + await connection.client.request("turn/interrupt", { threadId: binding.conversationId, turnId: connection.running }); + } + + acknowledge(botId: string, threadId: string, sendId: string): void { + const binding = this.owned(botId, threadId); + const delivery = binding.deliveries.find((delivery) => delivery.sendId === sendId); + if (delivery?.state !== "uncertain") refuse("Only an uncertain delivery can be acknowledged."); + delivery.state = "cancelled"; + this.persist(); + const connection = this.connections.get(threadId); + if (connection) void this.pump(binding, connection); + } + + async disconnect(botId: string, threadId: string): Promise { + const binding = this.owned(botId, threadId); + const connection = this.connections.get(threadId); + if (connection?.state === "connecting" || connection?.running || binding.deliveries.some((delivery) => ["queued", "dispatching", "delivered", "uncertain"].includes(delivery.state))) refuse("Cancel queued work and finish or review the native turn before disconnecting."); + await connection?.client?.close(); + await connection?.work; + this.bindings.delete(threadId); + this.connections.delete(threadId); + this.persist(); + } + + async close(): Promise { + this.stopped = true; + await Promise.all([...this.connections.values()].map((connection) => connection.client?.close())); + await Promise.all([...this.connections.values()].map((connection) => connection.work)); + } +} diff --git a/server/contracts.ts b/server/contracts.ts index 137ddf1b0ee..97e630e78b3 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -417,6 +417,7 @@ export interface TextGenerationOptions { } export interface ProviderInstance { + readonly nativeCodex?: () => Promise; readonly instanceId: InstanceId; readonly driverKind: DriverKind; readonly displayName: string | undefined; diff --git a/server/drivers/codex-native-client.test.ts b/server/drivers/codex-native-client.test.ts new file mode 100644 index 00000000000..fd10f58093e --- /dev/null +++ b/server/drivers/codex-native-client.test.ts @@ -0,0 +1,28 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import { connectCodexNative, type CodexNativeClient } from "./codex-native-client.ts"; + +const clients: CodexNativeClient[] = []; +const dirs: string[] = []; +afterEach(async () => { await Promise.all(clients.splice(0).map((client) => client.close())); for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +const cli = fileURLToPath(new URL("../testing/fake-codex-native.ts", import.meta.url)); + +it("uses the native stdio protocol without model, sandbox, approval or MCP overrides", async () => { + const dir = mkdtempSync(join(tmpdir(), "omb-native-cli-")); dirs.push(dir); + const client = await connectCodexNative(cli, { ...process.env, HOME: dir, USERPROFILE: dir, FAKE_CODEX_NATIVE_DIR: dir }); clients.push(client); + expect(client.home).toBe(dir); + const result = await client.request("thread/resume", { threadId: "fixture-native-existing", excludeTurns: true }); + expect(result.thread.id).toBe("fixture-native-existing"); + const calls = readFileSync(join(dir, "native-fixture-requests.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); + expect(calls.map((call) => call.method)).toEqual(["initialize", "initialized", "thread/resume"]); + expect(calls.every((call) => call.argv.includes("app-server") && !call.argv.includes("-c"))).toBe(true); +}); + +it("refuses installed versions without verified settings preservation before any app-server runs", async () => { + const dir = mkdtempSync(join(tmpdir(), "omb-native-version-")); dirs.push(dir); + await expect(connectCodexNative(cli, { ...process.env, HOME: dir, USERPROFILE: dir, FAKE_CODEX_NATIVE_DIR: dir, FAKE_CODEX_NATIVE_VERSION: "codex-cli 0.159.2" })).rejects.toThrow(/0.163/); + expect(() => readFileSync(join(dir, "native-fixture-requests.jsonl"))).toThrow(); +}); diff --git a/server/drivers/codex-native-client.ts b/server/drivers/codex-native-client.ts new file mode 100644 index 00000000000..476e8a88b9e --- /dev/null +++ b/server/drivers/codex-native-client.ts @@ -0,0 +1,127 @@ +import { homedir } from "node:os"; +import { realpath } from "node:fs/promises"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { execCli, killCliTree, spawnCli } from "../procs.ts"; +import { codexLaunch } from "./codex-process.ts"; +import { verifyCodexSavedPermissions } from "./codex-saved-permissions.ts"; + +export interface CodexNativeClient { + readonly home: string; + verifySavedPermissions(thread: { id: string; path?: unknown }): Promise; + request(method: string, params: unknown): Promise; + reply(id: string | number, result: unknown): void; + reject(id: string | number): void; + onMessage(listener: (message: any) => void | Promise): void; + onClose(listener: () => void): void; + close(): Promise; +} + +export class CodexNativeRejection extends Error {} + +/** Codex 0.163.x app-server, generated locally with `app-server generate-ts`; no configuration overrides. */ +export async function connectCodexNative(cli: string, env: Record): Promise { + const version = await new Promise((resolve, reject) => { + execCli(cli, ["--version"], { env, timeout: 8_000 }, (error, stdout) => error ? reject(new Error("Codex CLI is unavailable.")) : resolve(stdout.trim())); + }); + if (!/^codex-cli 0\.163\.\d+$/.test(version)) throw new Error("Existing conversation connections require Codex 0.163.x. This CLI version has not been verified."); + if (/(?:^|\s)(?:-c|--config|--profile|-p|--model|-m|--sandbox|--ask-for-approval)(?:\s|=|$)/.test(cli)) { + throw new Error("Use a Codex CLI without configuration overrides for an existing conversation."); + } + const child = spawnCli(...codexLaunch(cli, ["app-server"]), { + env, cwd: env.HOME || env.USERPROFILE || homedir(), stdio: ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + let sequence = 0; + let closed = false; + let stopping: Promise | undefined; + let buffer = ""; + let messageListener: (message: any) => void = () => {}; + const closeListeners = new Set<() => void>(); + let home = ""; + const pending = new Map }>(); + const fail = () => { + if (closed) return; + closed = true; + for (const request of pending.values()) { + clearTimeout(request.timer); + request.reject(new Error("Codex connection closed; delivery may be uncertain.")); + } + pending.clear(); + for (const listener of closeListeners) listener(); + }; + const send = (message: unknown) => { + if (closed || !child.stdin.writable) throw new Error("Codex is disconnected."); + child.stdin.write(`${JSON.stringify(message)}\n`); + }; + const close = () => stopping ??= (async () => { + fail(); + if (!await killCliTree(child, 3_000)) throw new Error("The Codex process could not be confirmed stopped."); + })(); + child.stderr.resume(); + child.on("error", fail); + child.on("close", fail); + child.stdin.on("error", fail); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + buffer += chunk; + if (Buffer.byteLength(buffer) > 64 * 1024 * 1024) { void close().catch(() => {}); return; } + let newline: number; + while (!closed && (newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + let message: any; + try { message = JSON.parse(line); } catch { void close().catch(() => {}); return; } + if (message && typeof message.id === "number" && !message.method) { + const request = pending.get(message.id); + if (!request) continue; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new CodexNativeRejection("Codex rejected the request. Check the conversation and account in Codex.")); + else request.resolve(message.result); + } else if (message?.method) messageListener(message); + } + }); + const client: CodexNativeClient = { + get home() { return home; }, + verifySavedPermissions: (thread) => verifyCodexSavedPermissions(home, thread), + request: (method, params) => new Promise((resolve, reject) => { + if (closed) { reject(new Error("Codex is disconnected.")); return; } + const id = ++sequence; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error("Codex request timed out; delivery may be uncertain.")); + void close().catch(() => {}); + }, 30_000); + timer.unref(); + pending.set(id, { resolve, reject, timer }); + try { send({ id, method, params }); } catch { void close().catch(() => {}); } + }), + reply: (id, result) => send({ id, result }), + reject: (id) => send({ id, error: { code: -32601, message: "Unsupported OpenMausBot approval interface; no action was approved." } }), + onMessage: (listener) => { messageListener = listener; }, + onClose: (listener) => { closeListeners.add(listener); if (closed) listener(); }, + close, + }; + try { + const initialized = await client.request("initialize", { clientInfo: { name: "openmausbot_native_bridge", version: "1" } }); + if (typeof initialized?.codexHome !== "string" || !initialized.codexHome) throw new Error("Codex did not identify its local conversation store."); + home = await realpath(initialized.codexHome); + send({ method: "initialized", params: {} }); + return client; + } catch (error) { await close(); throw error; } +} + +export function codexNativeAccess(cli: string, env: () => Record, disposed: () => boolean) { + const clients = new Set(); + return { + connect: async () => { + if (disposed()) throw new Error("This Codex instance was removed."); + const client = await connectCodexNative(cli, env()); + if (disposed()) { await client.close(); throw new Error("This Codex instance was removed."); } + clients.add(client); + client.onClose(() => clients.delete(client)); + return client; + }, + close: async () => { await Promise.all([...clients].map((client) => client.close())); }, + }; +} diff --git a/server/drivers/codex-saved-permissions.test.ts b/server/drivers/codex-saved-permissions.test.ts new file mode 100644 index 00000000000..615751403ac --- /dev/null +++ b/server/drivers/codex-saved-permissions.test.ts @@ -0,0 +1,38 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { verifyCodexSavedPermissions } from "./codex-saved-permissions.ts"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +function fixture() { + const home = mkdtempSync(join(tmpdir(), "omb-saved-codex-")); dirs.push(home); + mkdirSync(join(home, "sessions")); + const path = join(home, "sessions", "fixture.jsonl"); + const meta = { type: "session_meta", payload: { id: "selected-thread" } }; + const snapshot = { type: "event_msg", payload: { type: "thread_settings_applied", thread_id: "selected-thread", thread_settings: { cwd: home, approval_policy: "on-request", permission_profile: { fixture: "read-only" } } } }; + const write = (items: unknown[]) => writeFileSync(path, items.map((item) => JSON.stringify(item)).join("\n")); + return { home, path, meta, snapshot, write }; +} + +it("requires a thread-owned saved permission snapshot before resuming", async () => { + const h = fixture(); h.write([h.meta, h.snapshot]); + await expect(verifyCodexSavedPermissions(h.home, { id: "selected-thread", path: h.path })).resolves.toBeUndefined(); +}); + +it("refuses old histories and copied snapshots from another thread", async () => { + const h = fixture(); + for (const items of [[h.meta], [h.meta, { ...h.snapshot, payload: { ...h.snapshot.payload, thread_id: "foreign-thread" } }], [{ ...h.meta, payload: { id: "foreign-thread" } }, h.snapshot]]) { + h.write(items); + await expect(verifyCodexSavedPermissions(h.home, { id: "selected-thread", path: h.path })).rejects.toThrow(/could not be verified/); + } +}); + +it("refuses missing, remote-only, corrupt and outside-store histories", async () => { + const h = fixture(); h.write([h.meta, h.snapshot]); + await expect(verifyCodexSavedPermissions(h.home, { id: "selected-thread" })).rejects.toThrow(); + await expect(verifyCodexSavedPermissions(join(h.home, "another-account"), { id: "selected-thread", path: h.path })).rejects.toThrow(); + writeFileSync(h.path, "{corrupt"); + await expect(verifyCodexSavedPermissions(h.home, { id: "selected-thread", path: h.path })).rejects.toThrow(); +}); diff --git a/server/drivers/codex-saved-permissions.ts b/server/drivers/codex-saved-permissions.ts new file mode 100644 index 00000000000..a14bc46f7bb --- /dev/null +++ b/server/drivers/codex-saved-permissions.ts @@ -0,0 +1,37 @@ +import { constants } from "node:fs"; +import { open, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; + +function contained(root: string, file: string): boolean { + const suffix = relative(root, file); + return suffix !== "" && suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); +} + +/** Codex 0.163.0 restores permissions from a thread-owned ThreadSettingsApplied rollout snapshot. */ +export async function verifyCodexSavedPermissions(home: string, thread: { id: string; path?: unknown }): Promise { + let handle: Awaited> | undefined; + try { + if (typeof thread.path !== "string" || !isAbsolute(thread.path)) throw new Error(); + const canonicalFile = await realpath(thread.path); + const roots = await Promise.all(["sessions", "archived_sessions"].map((directory) => realpath(join(home, directory)).catch(() => null))); + if (!roots.some((root) => root && contained(root, canonicalFile))) throw new Error(); + handle = await open(canonicalFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const before = await handle.stat(); + if (!before.isFile() || before.size <= 0 || before.size > 64 * 1024 * 1024) throw new Error(); + let sessionId: string | undefined; + let snapshot = false; + for (const line of (await handle.readFile("utf8")).split("\n")) { + if (!line.trim()) continue; + const item = JSON.parse(line); + if (item.type === "session_meta") sessionId = item.payload?.id; + if (item.type === "event_msg" && item.payload?.type === "thread_settings_applied" && item.payload.thread_id === thread.id) { + const settings = item.payload.thread_settings; + snapshot = Boolean(settings && typeof settings.cwd === "string" && settings.approval_policy !== undefined && settings.permission_profile && typeof settings.permission_profile === "object"); + } + } + const after = await handle.stat(); + if (sessionId !== thread.id || !snapshot || before.size !== after.size || before.mtimeMs !== after.mtimeMs || await realpath(thread.path) !== canonicalFile) throw new Error(); + } catch { + throw new Error("Codex's saved permission snapshot could not be verified. No conversation was resumed or message sent. Review and save the original conversation's settings in Codex 0.163.x before connecting; remote-only or unsupported history stores cannot be bound."); + } finally { await handle?.close(); } +} diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index af5d9db88fd..1aa7fe965b5 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -8,7 +8,6 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import { homedir } from "node:os"; import { codexConfigMcp, codexConfigRunsServer, mountedMcpServerName } from "./codex-mcp-names.ts"; - import { DATA_DIR, stripWorkspaceCredentialEnv } from "../config.ts"; import { laterStartup, openStartupModelCatalog, writeStartupModelCache } from "../startup-model-catalog.ts"; import { hostedWorkspaceConfigured } from "../enterprise.ts"; @@ -45,6 +44,7 @@ import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instr import { volatileContextNote, withContextNote } from "./prompt-split.ts"; import type { ApprovalMode } from "../../shared/approval-mode.ts"; import { CodexDeviceAuthController } from "./codex-device-auth.ts"; +import { codexNativeAccess } from "./codex-native-client.ts"; import { codexAccountEmail, codexHome } from "./codex-identity.ts"; import { CODEX_MISSING_CREDENTIALS, CODEX_SIGN_IN_EXPIRED, codexLimitFacts, codexNotSignedIn, codexSignInExpired, codexSignInRefused, codexUserError, type CodexRoute } from "./codex-errors.ts"; import { codexLoginCheck, codexLoginStatus, storedCodexLogin } from "./codex-login.ts"; @@ -557,7 +557,7 @@ export const CodexDriver: ProviderDriver = { const planAuth = plan ? new ChatGptPlanAuthController({ directory: planDirectory }) : null; let planGeneration = 0; let planSigningOut = false; - let disposed = false; + let disposed = false; const nativeAccess = codexNativeAccess(config.cli, childEnv, () => disposed); let planWarning: string | undefined; let models = plan ? { default: "", options: [] } : config.managed ? { default: config.managed.models[0], options: config.managed.models.map(id => ({ id, label: id })) } : STATIC_CODEX_MODELS; const refreshModels = async (preserveExisting = false) => { @@ -1781,7 +1781,7 @@ export const CodexDriver: ProviderDriver = { }, refreshModels, ...(startupModelRefresh ? { startupModelRefresh } : {}), - generateMemoryText, + generateMemoryText, ...(!plan && !config.managed ? { nativeCodex: nativeAccess.connect } : {}), ...(plan ? { authenticationMethod: "browser-pkce" as const } : {}), startAuthentication: async () => { if (planUnavailable) throw new Error(planUnavailable); @@ -1796,7 +1796,7 @@ export const CodexDriver: ProviderDriver = { }, cancelAuthentication: () => (planAuth ?? authentication).cancel(), signOut: async () => { - if (!planAuth) { + if (!planAuth) { await nativeAccess.close(); const mark = loginMark(); await authentication.signOut(); loginAccepted(mark); @@ -1860,7 +1860,7 @@ export const CodexDriver: ProviderDriver = { }, }, dispose: async () => { - disposed = true; + disposed = true; await nativeAccess.close(); planGeneration++; await authentication.dispose(); await planAuth?.dispose(); diff --git a/server/host/codex-bindings.ts b/server/host/codex-bindings.ts new file mode 100644 index 00000000000..c58bba9355d --- /dev/null +++ b/server/host/codex-bindings.ts @@ -0,0 +1,15 @@ +import { CodexBindings, type CodexBindingsDeps } from "../codex-bindings.ts"; +import type { ProviderRegistry } from "../harness/registry.ts"; +import { createCodexBindingRoutes } from "../routes/codex-bindings.ts"; + +export function createCodexBindingsHost(deps: Omit & { registry: ProviderRegistry; enabled: boolean }) { + const bridge = new CodexBindings({ ...deps, connect: (instanceId) => { + if (!deps.enabled) throw new Error("Existing Codex connections require a local workspace."); + const connect = deps.registry.get(instanceId)?.nativeCodex; + if (!connect) throw new Error("This Codex instance does not support existing native conversations."); + return connect(); + } }); + const routes = createCodexBindingRoutes({ bridge, enabled: deps.enabled, instances: () => deps.registry.instances() + .filter((instance) => instance.enabled && instance.nativeCodex).map((instance) => ({ id: instance.instanceId, name: instance.displayName ?? instance.instanceId })) }); + return { bridge, routes }; +} diff --git a/server/index.ts b/server/index.ts index 2ac3a0a8893..63e24f73e41 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,5 +1,6 @@ // Import first: it reads a Cloud home's secrets off the launcher's pipe before any module can start a process. import { BOOT_CLOUD_SECRETS } from "./cloud-secrets-boot.ts"; +import { createCodexBindingsHost } from "./host/codex-bindings.ts"; import { AsyncLocalStorage } from "node:async_hooks"; import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { existsSync, readFileSync, realpathSync, rmSync } from "node:fs"; @@ -49,7 +50,6 @@ import { type CredentialTargetId, } from "../shared/credential-request.ts"; import { isPendingSignInRequest, normalizeSignInSite, signInFallbackText, signInOutcome, signInResumePrompt } from "../shared/sign-in-request.ts"; - import { createRoomTurnLevels } from "./room-turn-level.ts"; import { RoomMemberLock } from "./room-member-lock.ts"; import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode, undeliveredApprovalTool } from "./auto-approve.ts"; @@ -235,7 +235,6 @@ import { type GoalRunMember, } from "./group-goal-run.ts"; import type { GroupGoalRunCardData, GroupGoalRunStatus } from "../shared/group-goal-run.ts"; - import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.ts"; import { readMessageText, recallMessages, recentMessages, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing, logNotification, lastRowAt } from "./message-db.ts"; @@ -3046,6 +3045,7 @@ const store = new Store( (instanceId) => registry.cliTarget(instanceId)?.driverKind, () => appLanguage() ?? Intl.DateTimeFormat().resolvedOptions().locale, ); +const nativeCodexHost = createCodexBindingsHost({ dataDir: DATA_DIR, store, registry, busy: threadBusy, enabled: !CLOUD_HOME && !HOSTED_WORKSPACE }); const creditMoves = cloudCredit && createCreditMoves({ credit: cloudCredit, store, context: selectionContext, threadBusy, saveDefault: saveNewBotModel, defaults: () => [cfg.defaultModelSelection, cfg.newBotDefaults?.profile.modelSelection], engineName: id => registry.get(id)?.displayName ?? id, log: line => console.warn(line) }); @@ -8094,7 +8094,7 @@ async function startTurn( workspaceMaintenance.assertAvailable(); moveFreeze.assertTurns(); const profile = store.bot(botId); assertBotHere(profile); if (!profile) throw Object.assign(new Error("no such bot"), { status: 404 }); - const threadId = opts?.threadId ?? profile.threadId; + const threadId = opts?.threadId ?? profile.threadId; nativeCodexHost.bridge.assertUnbound(threadId); const continuingRoutine = opts?.cardContinuation ? activeRoutineRunForThread(threadId) : null; if (continuingRoutine) { const onDispatchError = opts?.onDispatchError; @@ -13160,10 +13160,10 @@ ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: orgInstal ROUTES.push(createSharedComputerRoutes({ enabled: lendingEnabled, computers: sharedComputers, sessionLive: (id) => sessions.isLive(id), environmentId: ENVIRONMENT_ID, cloudHomeLender: CLOUD_HOME ? CLOUD_HOME_LENDER : null, principal: sessionLendingPrincipal, personKey, power: () => cloudPower, links: CLOUD_HOME ? deviceLinks : null, upgrades })); // `routines` is assigned at boot, so it is read per request. -ROUTES.push(createBotMessageRoutes({ +ROUTES.push(nativeCodexHost.routes); ROUTES.push(createBotMessageRoutes({ store, registry, handoffs, phoneSecretSubmissions, directTurnDispatchClaims, groupSpeakers, routines: () => routines, - desktopManaged: DESKTOP_MANAGED, cloudHome: Boolean(CLOUD_HOME), DirectSendRefused, + desktopManaged: DESKTOP_MANAGED, cloudHome: Boolean(CLOUD_HOME), DirectSendRefused, codexBindings: nativeCodexHost.bridge, acceptDirectSend, directSendRefusal, startTurn, usageTriggerFor, messageSender, cloudGuestSendRefusal, cloudThreadRefusal, cardAnswerRefusal, commandRememberRefusal, requestBehavior, requestedTaskBot, botForThread, threadBusy, botAtThreadCapacity, parksBehindCoordination, activeGroupTurnForBot, runningTurnInstance, engineSteersImages, resolveReplyTarget, @@ -16875,7 +16875,7 @@ const gracefulShutdown = createGracefulShutdown({ webhookIngress?.server.close(); tunnelListener?.close(); stopCloudHomeConnectedApps(); }, - async () => { await managedDesktop.close(); await registry.disposeAll(); }, + async () => { await nativeCodexHost.bridge.close(); await managedDesktop.close(); await registry.disposeAll(); }, async () => { await dataEngine.closeAll().catch((error) => console.warn("data: close failed", error)); }, async () => { const temporaries = [...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)); diff --git a/server/routes/bot-messages.ts b/server/routes/bot-messages.ts index c637ac50d92..f5a5fa91960 100644 --- a/server/routes/bot-messages.ts +++ b/server/routes/bot-messages.ts @@ -18,6 +18,8 @@ import { spokenLineFields } from "../live-call-record.ts"; import type { PhoneSecretSubmissionRegistry } from "../phone-secret.ts"; import type { ReplyTarget } from "../replies.ts"; import type { RequestAuth } from "../request-auth.ts"; +import { ownerOrAdmin } from "../request-auth.ts"; +import type { CodexBindings } from "../codex-bindings.ts"; import type { RoutineManager } from "../routines.ts"; import { acceptedSendMatch, parseSendId } from "../send-idempotency.ts"; import { cancelSteeredMessage, holdSteeredQueue, queuedSteeredMessage, restoreHeldSteeredQueue, settleHeldSteeredQueue } from "../steer-queue.ts"; @@ -43,6 +45,7 @@ type CardAnswer = { botId: string; botName?: string; threadId: string; requestId type Behavior = "allow" | "deny" | "answer"; export interface BotMessageRouteDeps { + codexBindings?: CodexBindings; store: Pick; @@ -191,6 +194,12 @@ export function createBotMessageRoutes(deps: BotMessageRouteDeps): RouteHandler const threadId = body.threadId ?? bot.threadId; const notYours = cloudGuestSendRefusal(auth, threadId); if (notYours) return json(res, 403, { error: notYours }); + if (deps.codexBindings?.configured(threadId)) { + if (!ownerOrAdmin(auth)) return json(res, 403, { error: "Only the local workspace owner can send to this Codex conversation." }); + if (guarded || pressed || fromAnApp || body.replyToId || body.dataContext) return json(res, 409, { error: "This Codex connection accepts direct text and images only. No message was sent." }); + const message = await deps.codexBindings.send(bot.id, threadId, String(body.text ?? ""), parseSendId(body.sendId) ?? ""); + return json(res, 202, { ok: true, threadId, message }); + } const onBehalfOf = guardedBody?.data?.onBehalfOf; const trigger: UsageTrigger = onBehalfOf ? { kind: "user", ...(onBehalfOf.email ? { email: onBehalfOf.email } : {}), ...(onBehalfOf.name ? { label: onBehalfOf.name } : {}) } @@ -447,6 +456,11 @@ export function createBotMessageRoutes(deps: BotMessageRouteDeps): RouteHandler const behavior = requestBehavior(body.behavior); const reviewedSha256 = typeof body.reviewedSha256 === "string" ? body.reviewedSha256 : undefined; if (!behavior) return json(res, 400, { error: "behavior must be allow, deny, or answer" }); + if (deps.codexBindings?.configured(bot.threadId) && String(body.requestId).startsWith("codex-")) { + if (!ownerOrAdmin(auth)) return json(res, 403, { error: "Only the local workspace owner can answer Codex." }); + if (body.always || body.rememberCommand) return json(res, 400, { error: "This connection supports approval of the displayed request only." }); + return json(res, 200, { ok: true, outcome: await deps.codexBindings.answer(bot.id, bot.threadId, String(body.requestId), behavior, body.message) }); + } const refusal = cardAnswerRefusal(auth, selected.threadId, String(body.requestId), behavior) ?? (selected.threadId === bot.threadId ? null : cardAnswerRefusal(auth, bot.threadId, String(body.requestId), behavior)); if (refusal) return json(res, 403, { error: refusal }); @@ -517,6 +531,13 @@ export function createBotMessageRoutes(deps: BotMessageRouteDeps): RouteHandler if (!behavior) return json(res, 400, { error: "behavior must be allow, deny, or answer" }); const requestId = String(body.requestId); const threadId = answeredIn(m[1], requestId); + if (deps.codexBindings?.configured(threadId) && requestId.startsWith("codex-")) { + if (!ownerOrAdmin(auth)) return json(res, 403, { error: "Only the local workspace owner can answer Codex." }); + if (body.always || body.rememberCommand) return json(res, 400, { error: "This connection supports approval of the displayed request only." }); + const owner = store.botByThread(threadId); + if (!owner) return json(res, 404, { error: "No such thread." }); + return json(res, 200, { ok: true, outcome: await deps.codexBindings.answer(owner.id, threadId, requestId, behavior, body.message) }); + } const refusal = cardAnswerRefusal(auth, m[1], requestId, behavior) ?? (threadId === m[1] ? null : cardAnswerRefusal(auth, threadId, requestId, behavior)); if (refusal) return json(res, 403, { error: refusal }); if (body.rememberCommand === true) { @@ -643,6 +664,11 @@ export function createBotMessageRoutes(deps: BotMessageRouteDeps): RouteHandler if (expectedThreadId !== undefined && (typeof expectedThreadId !== "string" || !/^[\w-]+$/.test(expectedThreadId))) { return json(res, 400, { error: "threadId must be a task id" }); } + if (deps.codexBindings?.configured(expectedThreadId ?? bot.threadId)) { + if (!ownerOrAdmin(auth)) return json(res, 403, { error: "Only the local workspace owner can stop Codex." }); + await deps.codexBindings.cancel(bot.id, expectedThreadId ?? bot.threadId); + return json(res, 200, { ok: true }); + } if (typeof expectedThreadId === "string" && store.taskByThread(bot.id, expectedThreadId)) { const routine = routines!.activeBotRunForBot(bot.id); if (routine?.threadId === expectedThreadId) await routines!.cancelRun(routine.id); diff --git a/server/routes/codex-bindings.test.ts b/server/routes/codex-bindings.test.ts new file mode 100644 index 00000000000..418835b3c92 --- /dev/null +++ b/server/routes/codex-bindings.test.ts @@ -0,0 +1,71 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, expect, it, vi } from "vitest"; +import { json, readBody } from "../harness/http.ts"; +import { requiredScope, type RequestAuth } from "../request-auth.ts"; +import { createCodexBindingRoutes } from "./codex-bindings.ts"; +import { dispatchRoutes } from "./table.ts"; + +const servers: Server[] = []; +afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); }); +async function fixture(auth: RequestAuth = { kind: "loopback", scopes: ["admin"] }, enabled = true) { + const disconnected = { configured: false, connected: false, state: "disconnected" as const, deliveries: [] }; + const bridge = { + view: vi.fn(() => disconnected), list: vi.fn(async () => ({ conversations: [] })), + bind: vi.fn(async () => ({ ...disconnected, configured: true, state: "failed" as const })), + reconnect: vi.fn(async () => disconnected), cancel: vi.fn(async () => {}), acknowledge: vi.fn(), disconnect: vi.fn(async () => {}), + }; + const route = createCodexBindingRoutes({ bridge, enabled, instances: () => [{ id: "codex", name: "Native Codex" }] }); + const server = createServer(async (req, res) => { + const url = new URL(req.url!, "http://localhost"); + try { + if (!await dispatchRoutes([route], { req, res, url, path: url.pathname, method: req.method!, auth, json, readBody })) json(res, 404, {}); + } catch (error) { json(res, (error as { status?: number }).status ?? 500, { error: "refused" }); } + }); + servers.push(server); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const request = async (method: string, path: string, body?: unknown) => { + const response = await fetch(`${base}${path}`, { method, headers: { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + return { status: response.status, body: await response.json() }; + }; + return { bridge, request }; +} +const path = "/api/bots/owner-bot/threads/owner-thread/codex"; + +it("keeps conversation discovery, inspection and mutation admin scoped", () => { + for (const [method, url] of [["GET", "/api/codex/conversations"], ["GET", path], ["PUT", path], ["DELETE", path], ["POST", `${path}/cancel`]]) expect(requiredScope(method!, url!)).toBe("admin"); +}); + +it("refuses service loopback and hosted environments before reading native history", async () => { + for (const [auth, enabled] of [[{ kind: "loopback", trust: "service", scopes: ["client"] }, true], [{ kind: "loopback", scopes: ["admin"] }, false]] as const) { + const h = await fixture(auth as RequestAuth, enabled); + expect((await h.request("GET", "/api/codex/conversations?instanceId=codex")).status).toBe(403); + expect((await h.request("GET", path)).status).toBe(403); + expect(h.bridge.list).not.toHaveBeenCalled(); expect(h.bridge.view).not.toHaveBeenCalled(); + } +}); + +it("validates instance ownership and forbids configuration overrides", async () => { + const h = await fixture(); + expect((await h.request("GET", "/api/codex/conversations?instanceId=foreign")).status).toBe(404); + expect((await h.request("PUT", path, { instanceId: "codex", conversationId: "existing", approvalPolicy: "never" })).status).toBe(400); + expect((await h.request("PUT", path, { instanceId: "foreign", conversationId: "existing" })).status).toBe(404); + expect(h.bridge.bind).not.toHaveBeenCalled(); +}); + +it("returns a failed connection as configured but inactive, and exposes reconnect and disconnect", async () => { + const h = await fixture(); + expect(await h.request("PUT", path, { instanceId: "codex", conversationId: "existing" })).toMatchObject({ status: 409, body: { configured: true, connected: false, state: "failed" } }); + expect(h.bridge.bind).toHaveBeenCalledWith("owner-bot", "owner-thread", "codex", "existing"); + expect((await h.request("POST", `${path}/reconnect`, {})).status).toBe(200); + expect((await h.request("DELETE", path)).status).toBe(200); + expect(h.bridge.disconnect).toHaveBeenCalledWith("owner-bot", "owner-thread"); +}); + +it("scopes cancellation and uncertain-delivery review to the exact bot and thread", async () => { + const h = await fixture(); + expect((await h.request("POST", `${path}/cancel`, { sendId: "stable-send-identifier" })).status).toBe(200); + expect(h.bridge.cancel).toHaveBeenCalledWith("owner-bot", "owner-thread", "stable-send-identifier"); + expect((await h.request("POST", `${path}/acknowledge`, { sendId: "stable-send-identifier" })).status).toBe(200); + expect(h.bridge.acknowledge).toHaveBeenCalledWith("owner-bot", "owner-thread", "stable-send-identifier"); +}); diff --git a/server/routes/codex-bindings.ts b/server/routes/codex-bindings.ts new file mode 100644 index 00000000000..0a01948780a --- /dev/null +++ b/server/routes/codex-bindings.ts @@ -0,0 +1,44 @@ +import type { CodexBindings } from "../codex-bindings.ts"; +import { ownerOrAdmin } from "../request-auth.ts"; +import { PASS, type RouteHandler } from "./table.ts"; + +export function createCodexBindingRoutes(deps: { bridge: Pick; enabled: boolean; instances(): Array<{ id: string; name: string }> }): RouteHandler { + return async ({ req, res, url, path, method, auth, json, readBody }) => { + const match = path.match(/^\/api\/bots\/([\w-]+)\/threads\/([\w-]+)\/codex(?:\/(reconnect|cancel|acknowledge))?$/); + if (path !== "/api/codex/conversations" && !match) return PASS; + if (!ownerOrAdmin(auth) || !deps.enabled) return json(res, 403, { error: "Existing Codex conversations are available only to the owner of a local OpenMausBot workspace." }); + if (path === "/api/codex/conversations" && method === "GET") { + const instances = deps.instances(); + const instanceId = url.searchParams.get("instanceId"); + if (!instanceId) return json(res, 200, { instances }); + if (!instances.some((instance) => instance.id === instanceId)) return json(res, 404, { error: "No supported native Codex instance." }); + return json(res, 200, await deps.bridge.list(instanceId, (url.searchParams.get("search") ?? "").slice(0, 200), url.searchParams.get("cursor") ?? undefined)); + } + if (!match) return json(res, 405, { error: "Method not supported." }); + const [, botId, threadId, action] = match as [string, string, string, string?]; + deps.bridge.view(botId, threadId); + if (method === "GET" && !action) return json(res, 200, deps.bridge.view(botId, threadId)); + if (method === "DELETE" && !action) { await deps.bridge.disconnect(botId, threadId); return json(res, 200, deps.bridge.view(botId, threadId)); } + if (method !== "POST" && method !== "PUT") return json(res, 405, { error: "Method not supported." }); + const body = await readBody(req); + if (!body || typeof body !== "object" || Array.isArray(body)) return json(res, 400, { error: "Send a JSON object." }); + const keys = action === "cancel" || action === "acknowledge" ? ["sendId"] : action === "reconnect" ? [] : ["instanceId", "conversationId"]; + if (Object.keys(body).some((key) => !keys.includes(key))) return json(res, 400, { error: "Unsupported connection setting. Codex's saved settings cannot be overridden here." }); + if (action === "reconnect" && method === "POST") return json(res, 200, await deps.bridge.reconnect(botId, threadId)); + if (action === "cancel" && method === "POST") { + if (body.sendId !== undefined && typeof body.sendId !== "string") return json(res, 400, { error: "sendId must be a string." }); + await deps.bridge.cancel(botId, threadId, body.sendId); + return json(res, 200, deps.bridge.view(botId, threadId)); + } + if (action === "acknowledge" && method === "POST" && typeof body.sendId === "string") { + deps.bridge.acknowledge(botId, threadId, body.sendId); + return json(res, 200, deps.bridge.view(botId, threadId)); + } + if (!action && method === "PUT" && typeof body.instanceId === "string" && typeof body.conversationId === "string") { + if (!deps.instances().some((instance) => instance.id === body.instanceId)) return json(res, 404, { error: "No supported native Codex instance." }); + const result = await deps.bridge.bind(botId, threadId, body.instanceId, body.conversationId); + return json(res, result.connected ? 200 : 409, result); + } + return json(res, 400, { error: "Select a Codex instance and an exact existing conversation." }); + }; +} diff --git a/server/testing/fake-codex-native.ts b/server/testing/fake-codex-native.ts new file mode 100755 index 00000000000..3e05f235a61 --- /dev/null +++ b/server/testing/fake-codex-native.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env -S node --experimental-strip-types +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { join } from "node:path"; + +const root = process.env.FAKE_CODEX_NATIVE_DIR ?? process.env.HOME!; +const stateFile = join(root, "native-fixture-history.json"); +const logFile = join(root, "native-fixture-requests.jsonl"); +const nativeId = "fixture-native-existing"; +const rollout = join(root, "sessions", "native-fixture.jsonl"); +const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9xkAAAAASUVORK5CYII="; +if (process.argv.includes("--version")) { process.stdout.write(`${process.env.FAKE_CODEX_NATIVE_VERSION ?? "codex-cli 0.163.0"}\n`); process.exit(0); } +if (process.argv.includes("login")) { process.stderr.write("Logged in using ChatGPT\n"); process.exit(0); } +mkdirSync(join(root, "sessions"), { recursive: true }); +writeFileSync(rollout, [ + { type: "session_meta", payload: { id: nativeId } }, + { type: "event_msg", payload: { type: "thread_settings_applied", thread_id: nativeId, thread_settings: { cwd: root, approval_policy: "on-request", permission_profile: { fixture: "read-only" } } } }, +].map((item) => JSON.stringify(item)).join("\n")); +const send = (message: unknown) => process.stdout.write(`${JSON.stringify(message)}\n`); +const turns: any[] = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, "utf8")) : [{ id: "existing-fixture-turn", status: "completed", items: [{ id: "existing-fixture-image", type: "imageGeneration", result: png, status: "completed" }] }]; +const persist = () => writeFileSync(stateFile, JSON.stringify(turns)); +const thread = () => ({ id: nativeId, path: rollout, name: "Native fixture conversation", model: "fixture-native-model", modelProvider: "openai", reasoningEffort: "high", cwd: root, status: { type: "idle" }, turns: [] }); +const notify = (method: string, params: any) => send({ method, params: { threadId: nativeId, ...params } }); +const finish = (status = "completed") => { + const turn = turns.at(-1)!; + turn.status = status; + if (status === "completed") turn.items.push({ id: `reply-${turn.id}`, type: "agentMessage", text: `Native fixture reply: ${turn.items[0].content.filter((input: any) => input.type === "text").map((input: any) => input.text).join("")}` }); + persist(); + for (const item of turn.items.slice(1)) notify("item/completed", { turnId: turn.id, item }); + notify("turn/completed", { turn }); +}; +const lines = createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (!message.method) { + appendFileSync(logFile, `${JSON.stringify({ reply: message })}\n`); + if (message.id === "native-approval") finish(message.result?.decision === "accept" ? "completed" : "interrupted"); + return; + } + appendFileSync(logFile, `${JSON.stringify({ method: message.method, params: message.params, argv: process.argv.slice(2) })}\n`); + if (message.id === undefined) return; + const reply = (result: unknown) => send({ id: message.id, result }); + const params = message.params ?? {}; + switch (message.method) { + case "initialize": reply({ userAgent: "codex/0.163.0", codexHome: root, platformFamily: "unix", platformOs: "linux" }); break; + case "account/read": reply({ account: { type: "chatgpt", email: "fixture@example.test", planType: "pro" }, requiresOpenaiAuth: true }); break; + case "config/read": reply({ config: { model_provider: "openai" } }); break; + case "model/list": reply({ data: [{ id: "fixture-native-model", model: "fixture-native-model", displayName: "Fixture native", defaultReasoningEffort: "high", supportedReasoningEfforts: [{ reasoningEffort: "high", description: "High" }] }], nextCursor: null }); break; + case "thread/list": reply({ data: [thread()], nextCursor: null }); break; + case "thread/read": reply({ thread: thread() }); break; + case "thread/resume": reply({ thread: thread(), model: "fixture-native-model", modelProvider: "openai", cwd: root, approvalPolicy: "on-request", approvalsReviewer: "user", sandbox: { type: "readOnly" }, reasoningEffort: "high", collaborationMode: null }); break; + case "thread/turns/list": reply({ data: turns, nextCursor: null }); break; + case "turn/start": { + if (params.threadId !== nativeId || Object.keys(params).some((key) => !["threadId", "input", "clientUserMessageId"].includes(key))) { send({ id: message.id, error: { code: -32600, message: "Unexpected native target or overrides" } }); break; } + const turn = { id: `native-fixture-turn-${turns.length}`, status: "inProgress", items: [{ id: `user-${params.clientUserMessageId}`, type: "userMessage", clientId: params.clientUserMessageId, content: params.input }] }; + turns.push(turn); persist(); + reply({ turn }); notify("turn/started", { turn }); + const text = JSON.stringify(params.input); + if (text.includes("approval fixture")) send({ id: "native-approval", method: "item/commandExecution/requestApproval", params: { threadId: nativeId, turnId: turn.id, itemId: "fixture-command", command: "pwd", cwd: root, reason: "Harmless fixture approval" } }); + else if (!text.includes("hold fixture")) finish(); + break; + } + case "turn/interrupt": finish("interrupted"); reply({}); break; + default: send({ id: message.id, error: { code: -32601, message: "Unsupported fixture operation" } }); + } +}); diff --git a/shared/codex-binding.ts b/shared/codex-binding.ts new file mode 100644 index 00000000000..30d46a011d4 --- /dev/null +++ b/shared/codex-binding.ts @@ -0,0 +1,27 @@ +export type CodexConnectionState = "disconnected" | "connecting" | "ready" | "awaiting-approval" | "failed"; +export type CodexDeliveryState = "queued" | "dispatching" | "delivered" | "completed" | "interrupted" | "failed" | "uncertain" | "cancelled"; + +export interface CodexProvenance { + conversationId: string; + turnId: string; + itemId: string; +} + +export interface CodexBindingView { + configured: boolean; + connected: boolean; + state: CodexConnectionState; + instanceId?: string; + conversationId?: string; + title?: string; + error?: string; + running?: boolean; + settings?: { + model: string; + reasoningEffort: string | null; + cwd: string; + approvalPolicy: unknown; + sandbox: unknown; + }; + deliveries: Array<{ sendId: string; state: CodexDeliveryState; turnId?: string }>; +} diff --git a/shared/wire.ts b/shared/wire.ts index f53b54a7543..d98850d7387 100644 --- a/shared/wire.ts +++ b/shared/wire.ts @@ -424,6 +424,7 @@ export type CardAnswerer = ( /** One transcript line, serialized as stored. */ export interface WireMessage { + codex?: import("./codex-binding.ts").CodexProvenance; roomRequest?: { id: string; phase: "request" | "result" }; id: string; role: "bot" | "user"; diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index b0b3369b201..43a8c52b09b 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { CodexConnection, CodexSource } from "./CodexConnection"; import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type Dispatch } from "react"; import { useAdvancedMode } from "@/lib/interface-mode"; import { ThreadLinkContext } from "@/components/ThreadLinkMenu"; @@ -120,11 +121,9 @@ import { latestFailure, latestReply, type TranscriptSnapshot } from "@/lib/trans import { pendingApprovals } from "./PendingApproval"; import { TranscriptAnnouncer } from "./TranscriptAnnouncer"; import { JumpToLatestButton } from "./JumpToLatestButton"; - const USER_COLLAPSE_CHARS = 600; const USER_COLLAPSE_LINES = 8; export const SIMPLE_BUBBLE = "rounded-[23px] font-normal"; - interface ChatRows { botId: string; threadId: string; @@ -145,15 +144,12 @@ interface ChatRows { conversational: boolean; simple?: boolean; canReact: boolean; openedAt: number; } - const ChatRowsContext = createContext(null); - function useChatRows(): ChatRows { const rows = useContext(ChatRowsContext); if (!rows) throw new Error("a chat row outside ChatView"); return rows; } - const drawnBot = (bot: Bot) => [bot.id, bot.name, bot.hidden, bot.color, bot.mascotBody, bot.mascotExpression, bot.avatarUrl, bot.avatarCrop, bot.avatarZoom, bot.avatarFocusX, bot.avatarFocusY].join("\u0001"); @@ -569,6 +565,7 @@ const Bubble = memo(function Bubble({ ))} )} + {message.codex && } {!group && } {split ? ( (); + const nativeChanged = useCallback((view: import("../../shared/codex-binding").CodexBindingView) => setNativeState({ threadId: bot.threadId, view }), [bot.threadId]); + const nativeView = nativeState?.threadId === bot.threadId ? nativeState.view : undefined; const computerStarting = computerStartLine(state.computerStarts[bot.id], bot.name); const mascotMotion = state.mascotMotion?.botId === bot.id ? state.mascotMotion : null; @@ -1242,7 +1242,7 @@ export function ChatView({ bot: profile }: { bot: Bot }) { {/* Simple mode moves the model, the computer and ⋯ into the bot panel; a remote client's panel has neither. */} - {advanced && !remoteClient && } + {advanced && !remoteClient && !nativeView?.configured && } openThread(dispatch, { botId, threadId }, state, showThreads)} /> {(advanced || remoteClient) && } @@ -1398,7 +1398,7 @@ export function ChatView({ bot: profile }: { bot: Bot }) { {canWrite === false ? ( dispatch({ type: "newTask", botId: bot.id })} /> ) : ( - t(CONNECTION_LABELS[state]); +const DELIVERY_LABELS = { queued: "codex.queued", dispatching: "codex.dispatching", delivered: "codex.delivered", completed: "codex.completed", interrupted: "codex.interrupted", failed: "codex.failed", uncertain: "codex.uncertain", cancelled: "codex.cancelled" } as const; +const deliveryLabel = (state: CodexDeliveryState) => t(DELIVERY_LABELS[state]); + +export function CodexSource({ conversationId, turnId, itemId }: { conversationId: string; turnId: string; itemId: string }) { + return

+ {t("codex.replySource")} +
{t("codex.conversation")}
{conversationId}
{t("codex.turn")}
{turnId}
{t("codex.item")}
{itemId}
+
; +} + +export function CodexConnection({ botId, threadId, onChange }: { botId: string; threadId: string; onChange?: (view: CodexBindingView) => void }) { + const owner = useOwnerOrAdmin(); + const id = useId(); + const [view, setView] = useState(); + const [instances, setInstances] = useState>([]); + const [instanceId, setInstanceId] = useState(""); + const [conversationId, setConversationId] = useState(""); + const [search, setSearch] = useState(""); + const [results, setResults] = useState>(); + const [cursor, setCursor] = useState(null); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const [confirm, setConfirm] = useState(false); + const [unavailable, setUnavailable] = useState(false); + const alive = useRef(true); + const composing = useRef(false); + const epoch = useRef(0); + const endpoint = `/api/bots/${botId}/threads/${threadId}/codex`; + useEffect(() => { if (view) onChange?.(view); }, [view, onChange]); + useEffect(() => { + if (!owner) return; + alive.current = true; + const abort = new AbortController(); + const load = async () => { + const generation = epoch.current; + try { + const next = await api(endpoint, { signal: abort.signal, background: true }); + if (alive.current && generation === epoch.current) { setView(next); setUnavailable(false); } + } catch (error) { + if (error instanceof ApiError && error.status === 403) setUnavailable(true); + else if (!abort.signal.aborted && alive.current) setError(t("codex.statusUnavailable")); + } + }; + void load(); + void api<{ instances: Array<{ id: string; name: string }> }>("/api/codex/conversations", { signal: abort.signal, background: true }).then((next) => { + if (!alive.current) return; + setInstances(next.instances); + setInstanceId(next.instances[0]?.id ?? ""); + }).catch(() => {}); + const timer = setInterval(() => void load(), 2_000); + return () => { alive.current = false; abort.abort(); clearInterval(timer); }; + }, [endpoint, owner]); + + if (!owner || unavailable || !view || !view.configured && !instances.length) return null; + const perform = async (work: () => Promise) => { + if (pending) return; + epoch.current++; setPending(true); setError(""); + try { await work(); } + catch (error) { if (alive.current) setError(error instanceof Error ? error.message : t("codex.failed")); } + finally { if (alive.current) setPending(false); } + }; + const mutate = (method: string, action = "", body?: unknown) => perform(async () => { + try { + const next = await api(`${endpoint}${action}`, { method, body: body === undefined ? undefined : JSON.stringify(body), timeoutMs: 120_000 }); + if (alive.current) { setView(next); setConfirm(false); } + } catch (error) { + if (error instanceof ApiError && typeof error.body?.state === "string" && alive.current) setView(error.body as unknown as CodexBindingView); + throw error; + } + }); + const find = (more = false) => perform(async () => { + const query = new URLSearchParams({ instanceId, search, ...(more && cursor ? { cursor } : {}) }); + const found = await api<{ conversations: Array<{ id: string; title: string }>; nextCursor: string | null }>(`/api/codex/conversations?${query}`, { timeoutMs: 60_000 }); + if (alive.current) { setResults((previous) => more ? [...previous ?? [], ...found.conversations] : found.conversations); setCursor(found.nextCursor); } + }); + return
+ + {t("codex.connection")} · {stateLabel(view.state)}{view.running ? ` · ${t("codex.running")}` : ""} + +
+

{t("codex.connectionHint")}

+ {view.configured ? <> +

{view.title}
{view.conversationId}

+ {view.settings &&
+
{t("codex.model")}
{view.settings.model} · {view.settings.reasoningEffort ?? t("codex.defaultEffort")}
+
{t("codex.folder")}
{view.settings.cwd}
+
{t("codex.permissions")}
{JSON.stringify(view.settings.approvalPolicy)} · {JSON.stringify(view.settings.sandbox)}
+
} +
+ {!view.connected && } + {view.running && } + +
+ {view.deliveries.length > 0 &&
    + {view.deliveries.slice(-20).map((delivery) =>
  • + {delivery.sendId.slice(0, 12)}{deliveryLabel(delivery.state)} + {delivery.state === "queued" && } + {delivery.state === "uncertain" && <>{t("codex.reviewHint")}} +
  • )} +
} + :
{ event.preventDefault(); if (!composing.current) void find(); }} className="space-y-2"> + + + +
{ composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} className={`${fieldClass} flex-1`} value={search} onChange={(event) => setSearch(event.target.value)} disabled={pending} /> + {search && } +
+ {results &&
    {results.map((result) =>
  • )}{!results.length &&
  • {t("codex.noResults")}
  • }
} + {cursor && results && } + + setConversationId(event.target.value)} disabled={pending} /> + +
} + {pending &&

{t("codex.working")}

} + {(error || view.error) &&

{error || view.error}

} +
+ setConfirm(false)} onConfirm={() => void mutate("DELETE")} /> +
; +} diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 8f8f955642b..4eb49fc379d 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -98,7 +98,7 @@ export function Composer({ onClearReply, onConsumeReply, onRestoreReply, - locked: setupLocked = false, + locked: setupLocked = false, nativeConversation = false, nativeDisconnected = false, }: { bot?: Bot; group?: Group; @@ -108,10 +108,10 @@ export function Composer({ onClearReply?: () => void; onConsumeReply?: () => void; onRestoreReply?: (message: Message, threadId: string) => void; - locked?: boolean; + locked?: boolean; nativeConversation?: boolean; nativeDisconnected?: boolean; }) { const bot = profile ? currentTaskBot(profile) : undefined; - const locked = setupLocked || Boolean(bot?.awaitingThreadSnapshot); + const locked = setupLocked || nativeDisconnected || Boolean(bot?.awaitingThreadSnapshot); const { state, dispatch } = useStore(); const ownerOrAdmin = useOwnerOrAdmin(); const { threads, currentBotId } = useThreadRefs(); @@ -268,7 +268,7 @@ export function Composer({ const typedGoalText = group && !group.dm ? goalTextFromComposer(text) : null; const effectiveText = typedGoalText ?? text; const effectiveChannelMode = typedGoalText !== null ? "goal" : channelMode; - const engineSupportsImages = imageTargetsSupport(effectiveText, effectiveChannelMode); + const engineSupportsImages = nativeConversation || imageTargetsSupport(effectiveText, effectiveChannelMode); const slash = composerSlashTrigger(text, caret); const locale = activeLocale(); @@ -854,8 +854,8 @@ export function Composer({ } {/* Simple mode keeps the chip only while Finish together is on, so the + menu's choice stays visible. */} {goal && (advanced || goal.on) && } - {advanced && approvalChoice && } - {modeBot && !remoteClient && advanced && ( + {advanced && !nativeConversation && approvalChoice && } + {modeBot && !remoteClient && advanced && !nativeConversation && ( )} - {bot && !group && } + {bot && !group && !nativeConversation && } {group && !advanced && } {hasContent && !locked && (