From a8734a972495cf343f628d1017e87775767aade5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 27 Aug 2026 14:25:31 +0200 Subject: [PATCH] Update project licensing (#3944) --- LICENSE | 874 +++++------------- README.ja.md | 3 +- README.ko.md | 2 +- README.md | 3 +- README.zh-CN.md | 3 +- docs/architecture.md | 2 +- docs/product.md | 2 +- nix/npm-deps.hash | 2 +- package-lock.json | 4 +- package.json | 2 +- .../ios/PaseoHardwareKeyboard.podspec | 2 +- .../pane/element-selector.electron.test.ts | 129 +++ .../browser/pane/element-selector.electron.ts | 222 +++-- .../diff-document/native-hit-testing.test.ts | 18 + packages/app/src/hooks/use-sidebar-pins.ts | 21 +- packages/app/src/i18n/resources.test.ts | 5 + packages/app/src/i18n/resources/ru.ts | 4 +- packages/app/src/lib/overlay-root.test.tsx | 8 +- packages/app/src/lib/overlay-root.ts | 5 + packages/app/src/polyfills/crypto.test.ts | 28 + packages/app/src/polyfills/crypto.ts | 16 +- .../screens/workspace/workspace-screen.tsx | 5 - packages/app/src/utils/projects.test.ts | 16 + packages/app/src/utils/projects.ts | 15 +- packages/cli/src/commands/agent/ls.ts | 21 +- packages/cli/tests/04-agent-ls.test.ts | 7 +- packages/desktop/package.json | 2 +- packages/desktop/src/daemon/daemon-manager.ts | 55 +- .../src/features/browser-capture.test.ts | 66 ++ .../desktop/src/features/browser-capture.ts | 109 +++ packages/desktop/src/features/opener.test.ts | 74 +- packages/desktop/src/features/opener.ts | 38 +- packages/desktop/src/main.ts | 248 ++--- .../src/window/desktop-window-owner.test.ts | 82 ++ .../src/window/desktop-window-owner.ts | 100 ++ packages/relay/src/encrypted-channel.ts | 42 +- .../server/src/server/agent/agent-manager.ts | 29 +- .../server/agent/provider-snapshot-manager.ts | 43 +- .../server/agent/providers/acp-agent.test.ts | 16 + .../src/server/agent/providers/acp-agent.ts | 18 +- .../providers/claude/agent.redesign.test.ts | 15 + .../server/agent/providers/claude/agent.ts | 38 +- .../providers/codex-app-server-agent.test.ts | 62 +- .../agent/providers/codex-app-server-agent.ts | 122 +-- .../providers/codex/tool-call-mapper.test.ts | 18 + .../agent/providers/codex/tool-call-mapper.ts | 38 +- .../providers/jsonl-frame-decoder.test.ts | 57 ++ .../agent/providers/jsonl-frame-decoder.ts | 183 ++++ .../agent/providers/jsonl-rpc-process.ts | 260 +----- .../server/agent/providers/omp/cli-runtime.ts | 66 +- .../providers/omp/protocol-session.test.ts | 77 ++ .../agent/providers/omp/protocol-session.ts | 53 ++ .../server/agent/providers/opencode-agent.ts | 32 +- .../server/agent/providers/pi/cli-runtime.ts | 29 +- packages/server/src/server/auth.ts | 32 +- packages/server/src/server/bootstrap.ts | 47 +- .../src/server/file-explorer/service.ts | 33 +- .../hub/test-utils/relationship-harness.ts | 33 +- packages/server/src/server/private-files.ts | 27 +- .../quota-fetcher/providers/claude.ts | 43 +- .../services/quota-fetcher/providers/grok.ts | 39 +- .../content/alternatives/claude-desktop.md | 4 +- .../src/content/alternatives/codex-app.md | 4 +- .../src/content/alternatives/conductor.md | 4 +- .../src/content/alternatives/happy-coder.md | 4 +- .../src/content/alternatives/openchamber.md | 4 +- .../content/alternatives/opencode-desktop.md | 6 +- .../src/content/alternatives/superset.md | 8 +- packages/website/src/llms.ts | 2 +- packages/website/src/routes/privacy.tsx | 5 +- public-docs/security.md | 8 +- 71 files changed, 1940 insertions(+), 1754 deletions(-) create mode 100644 packages/app/src/desktop/browser/pane/element-selector.electron.test.ts create mode 100644 packages/app/src/polyfills/crypto.test.ts create mode 100644 packages/desktop/src/features/browser-capture.test.ts create mode 100644 packages/desktop/src/features/browser-capture.ts create mode 100644 packages/desktop/src/window/desktop-window-owner.test.ts create mode 100644 packages/desktop/src/window/desktop-window-owner.ts create mode 100644 packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts create mode 100644 packages/server/src/server/agent/providers/jsonl-frame-decoder.ts create mode 100644 packages/server/src/server/agent/providers/omp/protocol-session.test.ts create mode 100644 packages/server/src/server/agent/providers/omp/protocol-session.ts diff --git a/LICENSE b/LICENSE index 4a76727e5d..2f5903143b 100644 --- a/LICENSE +++ b/LICENSE @@ -2,670 +2,210 @@ Copyright (c) 2025-present Mohamed Boudra Portions of this software are licensed as follows: -* All third party components incorporated into the Paseo Software are - licensed under the original license provided by the owner of the - applicable component. -* All content outside of the above mentioned restrictions is available - under the "AGPLv3" license as defined below. - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +* Third-party components incorporated into Paseo remain licensed under + the licenses provided by their respective copyright holders. +* Except for those third-party components, Paseo is licensed under the + Apache License, Version 2.0, reproduced below. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.ja.md b/README.ja.md index 0f35d47c57..6629ccc397 100644 --- a/README.ja.md +++ b/README.ja.md @@ -154,9 +154,8 @@ npm run typecheck ## 関連プロジェクト - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー -- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能 ## ライセンス -AGPL-3.0 +Apache-2.0 diff --git a/README.ko.md b/README.ko.md index 9975a7dd63..66f74f45fa 100644 --- a/README.ko.md +++ b/README.ko.md @@ -169,4 +169,4 @@ npm run typecheck ## 라이선스 -AGPL-3.0 +Apache-2.0 diff --git a/README.md b/README.md index 1b26f31800..d3b9022778 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,8 @@ npm run typecheck ## Related projects - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir -- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension ## License -AGPL-3.0 +Apache-2.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index fef0f4ff2c..c9c643b48f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -154,7 +154,6 @@ npm run typecheck ## 相关项目 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写 -- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展 ### 自托管 relay TLS @@ -205,4 +204,4 @@ server { ## License -AGPL-3.0 +Apache-2.0 diff --git a/docs/architecture.md b/docs/architecture.md index a1288d0e91..854e52fd45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,7 +162,7 @@ Communicates with the daemon via the same WebSocket protocol as the app. Enables remote access when the daemon is behind a firewall. -- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption +- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305 - The relay is zero-knowledge — it routes encrypted bytes and cannot read content - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Pairing via QR code transfers the daemon's public key to the client diff --git a/docs/product.md b/docs/product.md index eac71c160c..ad47f5694a 100644 --- a/docs/product.md +++ b/docs/product.md @@ -16,7 +16,7 @@ Freedom and flexibility. Every design decision follows from this: - **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal. - **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency. - **Respectful** - No telemetry, no forced cloud, no forced accounts -- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute. +- **Open source** — Apache-2.0. Users can inspect, fork, and contribute. - **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top. ## How it works diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 16691994cb..bdc5d4a7ba 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-/EvXKNUfLv4uzSiMGA2LxbmkCiezXQPbBiP74jseNPo= +sha256-og2UgicET2crFwEazRlBA4Av/IPWR7wtyP8CIqTLCcE= diff --git a/package-lock.json b/package-lock.json index f57e17edb1..b63027de28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "paseo", "version": "0.6.1", "hasInstallScript": true, - "license": "AGPL-3.0-or-later", + "license": "Apache-2.0", "workspaces": [ "packages/expo-two-way-audio", "packages/highlight", @@ -37630,7 +37630,7 @@ "packages/desktop": { "name": "@getpaseo/desktop", "version": "0.6.1", - "license": "AGPL-3.0-or-later", + "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", "@getpaseo/server": "*", diff --git a/package.json b/package.json index f66610dc22..b6d8c5dc36 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "voice-assistant" ], "homepage": "https://paseo.sh", - "license": "AGPL-3.0-or-later", + "license": "Apache-2.0", "author": { "name": "Mohamed Boudra", "email": "hello@moboudra.com" diff --git a/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboard.podspec b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboard.podspec index 84a8f75c72..58fa15b976 100644 --- a/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboard.podspec +++ b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboard.podspec @@ -5,7 +5,7 @@ Pod::Spec.new do |s| s.version = '0.1.0' s.summary = 'Hardware keyboard shortcuts for Paseo' s.description = 'Hardware keyboard shortcuts for Paseo' - s.license = 'AGPL-3.0-or-later' + s.license = 'Apache-2.0' s.author = 'Paseo' s.homepage = 'https://paseo.sh' s.platforms = { :ios => '13.4' } diff --git a/packages/app/src/desktop/browser/pane/element-selector.electron.test.ts b/packages/app/src/desktop/browser/pane/element-selector.electron.test.ts new file mode 100644 index 0000000000..61f067e447 --- /dev/null +++ b/packages/app/src/desktop/browser/pane/element-selector.electron.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + createElementSelectorController, + type BrowserElementSelection, + type ElementSelectorOutcome, + type ElementSelectorRuntime, + type ElementSelectorWebview, +} from "./element-selector.electron"; + +function selectorHarness() { + let installed: ((state: "installed" | "loading" | "unavailable") => void) | undefined; + let result: ((selection: BrowserElementSelection | null) => void) | undefined; + let timeout: (() => void) | undefined; + const cleared: string[] = []; + const destroyed: string[] = []; + const stopped: string[] = []; + const cancelledTimeouts: number[] = []; + const runtime: ElementSelectorRuntime = { + token: () => "session-1", + install: () => + new Promise((resolve) => { + installed = resolve; + }), + watch: (_webview, token, onResult) => { + result = onResult; + return () => stopped.push(token); + }, + clear: (_webview, token) => cleared.push(token), + destroy: (_webview, token) => destroyed.push(token), + timeout: (callback) => { + timeout = callback; + return 7; + }, + cancelTimeout: (id) => cancelledTimeouts.push(id), + }; + const webview = { isConnected: true, isLoading: () => false } as ElementSelectorWebview; + return { + runtime, + webview, + cleared, + destroyed, + stopped, + cancelledTimeouts, + install: (state: "installed" | "loading" | "unavailable") => installed?.(state), + select: (selection: BrowserElementSelection | null) => result?.(selection), + expire: () => timeout?.(), + }; +} + +async function settleInstallation(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("element selector owner", () => { + it("owns installation through selected completion", async () => { + const harness = selectorHarness(); + const outcomes: ElementSelectorOutcome[] = []; + const controller = createElementSelectorController(harness.runtime); + + expect( + controller.start({ + webview: harness.webview, + mode: "annotate", + onFinish: (outcome) => outcomes.push(outcome), + }), + ).toBe("started"); + harness.install("installed"); + await settleInstallation(); + const selection: BrowserElementSelection = { + url: "https://example.test/form", + selector: "#submit", + tag: "button", + text: "Submit", + outerHTML: '', + computedStyles: {}, + boundingRect: { x: 10, y: 20, width: 80, height: 30 }, + reactSource: null, + parentChain: ["form"], + children: [], + }; + harness.select(selection); + + expect(outcomes).toEqual([ + { + type: "selected", + mode: "annotate", + selection, + }, + ]); + expect(harness.stopped).toEqual(["session-1"]); + expect(harness.cancelledTimeouts).toEqual([7]); + }); + + it("cleans up a cancelled session even when guest installation finishes late", async () => { + const harness = selectorHarness(); + const outcomes: ElementSelectorOutcome[] = []; + const controller = createElementSelectorController(harness.runtime); + + controller.start({ + webview: harness.webview, + mode: "screenshot", + onFinish: (outcome) => outcomes.push(outcome), + }); + controller.cancel(); + harness.install("installed"); + await settleInstallation(); + + expect(outcomes).toEqual([{ type: "cancelled" }]); + expect(harness.cleared).toEqual(["session-1"]); + expect(harness.destroyed).toEqual(["session-1"]); + }); + + it("destroys the guest session when selection times out", () => { + const harness = selectorHarness(); + const outcomes: ElementSelectorOutcome[] = []; + const controller = createElementSelectorController(harness.runtime); + + controller.start({ + webview: harness.webview, + mode: "annotate", + onFinish: (outcome) => outcomes.push(outcome), + }); + harness.expire(); + + expect(outcomes).toEqual([{ type: "failed", reason: "timeout" }]); + expect(harness.destroyed).toEqual(["session-1"]); + }); +}); diff --git a/packages/app/src/desktop/browser/pane/element-selector.electron.ts b/packages/app/src/desktop/browser/pane/element-selector.electron.ts index 3161168284..ea415f6428 100644 --- a/packages/app/src/desktop/browser/pane/element-selector.electron.ts +++ b/packages/app/src/desktop/browser/pane/element-selector.electron.ts @@ -13,7 +13,7 @@ export type ElementSelectorOutcome = export type ElementSelectorStartResult = "started" | "loading" | "unavailable"; -interface ElementSelectorWebview extends HTMLElement { +export interface ElementSelectorWebview extends HTMLElement { isLoading?: () => boolean; executeJavaScript?: (code: string) => Promise; } @@ -37,6 +37,23 @@ export interface ElementSelectorController { stopForWebview(webview: ElementSelectorWebview): void; } +export interface ElementSelectorRuntime { + token(): string; + install( + webview: ElementSelectorWebview, + sessionToken: string, + ): Promise<"installed" | "loading" | "unavailable">; + watch( + webview: ElementSelectorWebview, + sessionToken: string, + onResult: (selection: BrowserElementSelection | null) => void, + ): () => void; + clear(webview: ElementSelectorWebview, sessionToken: string): void; + destroy(webview: ElementSelectorWebview, sessionToken: string): void; + timeout(callback: () => void): number; + cancelTimeout(timeoutId: number): void; +} + const SELECTOR_TIMEOUT_MS = 30_000; function executeWebviewJavaScript(webview: ElementSelectorWebview, code: string): Promise { @@ -84,15 +101,6 @@ function readSelectorInstallation( return Reflect.get(value, "reason") === "document-loading" ? "loading" : "unavailable"; } -function stopSessionTimers(session: ElementSelectorSession): void { - session.stopPolling?.(); - session.stopPolling = undefined; - if (session.timeoutId !== undefined) { - window.clearTimeout(session.timeoutId); - session.timeoutId = undefined; - } -} - function startSelectorResultPolling(input: { webview: ElementSelectorWebview; sessionToken: string; @@ -373,98 +381,112 @@ function buildElementSelectorScript(sessionToken: string): string { `; } -export function createElementSelectorController(): ElementSelectorController { - let currentSession: ElementSelectorSession | null = null; - let sessionCounter = 0; - - const finish = ( - session: ElementSelectorSession, - outcome: ElementSelectorOutcome, - guestCleanup: "clear" | "destroy" | null, - ): boolean => { - stopSessionTimers(session); - if (currentSession !== session) { - return false; - } - currentSession = null; - if (guestCleanup === "clear") { - clearWebviewSelector(session.webview, session.token); - } else if (guestCleanup === "destroy") { - destroyWebviewSelector(session.webview, session.token); - } - session.onFinish(outcome); - return true; - }; - +function browserSelectorRuntime(): ElementSelectorRuntime { + let sequence = 0; return { - start({ webview, mode, onFinish }) { - if (!webview.isConnected) { - return "unavailable"; - } - if (webview.isLoading?.()) { - return "loading"; - } - - if (currentSession) { - finish(currentSession, { type: "cancelled" }, "clear"); - } - - sessionCounter += 1; - const session: ElementSelectorSession = { - mode, - token: `${sessionCounter}:${crypto.randomUUID()}`, + token() { + sequence += 1; + return `${sequence}:${crypto.randomUUID()}`; + }, + async install(webview, sessionToken) { + const result = await executeWebviewJavaScript( webview, - onFinish, - }; - currentSession = session; - session.timeoutId = window.setTimeout(() => { - finish(session, { type: "failed", reason: "timeout" }, "destroy"); - }, SELECTOR_TIMEOUT_MS); - - void executeWebviewJavaScript(webview, buildElementSelectorScript(session.token)) - .then((installation) => { - const installationState = readSelectorInstallation(installation, session.token); - if (currentSession !== session) { - if (installationState === "installed") { - destroyWebviewSelector(webview, session.token); - } - return undefined; - } - if (installationState !== "installed") { - finish(session, { type: "failed", reason: installationState }, null); - return undefined; - } - session.stopPolling = startSelectorResultPolling({ - webview, - sessionToken: session.token, - onResult: (selection) => { - finish( - session, - selection - ? { type: "selected", mode: session.mode, selection } - : { type: "cancelled" }, - null, - ); - }, - }); - return undefined; - }) - .catch(() => { - finish(session, { type: "failed", reason: "unavailable" }, "destroy"); - return undefined; - }); - - return "started"; - }, - cancel() { - if (currentSession) { - finish(currentSession, { type: "cancelled" }, "clear"); - } - }, - stopForWebview(webview) { - if (currentSession?.webview === webview) { - finish(currentSession, { type: "cancelled" }, "destroy"); - } + buildElementSelectorScript(sessionToken), + ); + return readSelectorInstallation(result, sessionToken); }, + watch: (webview, sessionToken, onResult) => + startSelectorResultPolling({ webview, sessionToken, onResult }), + clear: clearWebviewSelector, + destroy: destroyWebviewSelector, + timeout: (callback) => window.setTimeout(callback, SELECTOR_TIMEOUT_MS), + cancelTimeout: (timeoutId) => window.clearTimeout(timeoutId), }; } + +class ElementSelectorOwner implements ElementSelectorController { + private current: ElementSelectorSession | null = null; + + constructor(private readonly runtime: ElementSelectorRuntime) {} + + start({ + webview, + mode, + onFinish, + }: Parameters[0]): ElementSelectorStartResult { + if (!webview.isConnected) return "unavailable"; + if (webview.isLoading?.()) return "loading"; + if (this.current) this.finish(this.current, { type: "cancelled" }, "clear"); + const session: ElementSelectorSession = { + mode, + token: this.runtime.token(), + webview, + onFinish, + }; + this.current = session; + session.timeoutId = this.runtime.timeout(() => + this.finish(session, { type: "failed", reason: "timeout" }, "destroy"), + ); + void this.install(session); + return "started"; + } + + cancel(): void { + if (this.current) this.finish(this.current, { type: "cancelled" }, "clear"); + } + + stopForWebview(webview: ElementSelectorWebview): void { + if (this.current?.webview === webview) { + this.finish(this.current, { type: "cancelled" }, "destroy"); + } + } + + private async install(session: ElementSelectorSession): Promise { + let state: "installed" | "loading" | "unavailable"; + try { + state = await this.runtime.install(session.webview, session.token); + } catch { + this.finish(session, { type: "failed", reason: "unavailable" }, "destroy"); + return; + } + if (this.current !== session) { + if (state === "installed") this.runtime.destroy(session.webview, session.token); + return; + } + if (state !== "installed") { + this.finish(session, { type: "failed", reason: state }, null); + return; + } + session.stopPolling = this.runtime.watch(session.webview, session.token, (selection) => + this.finish( + session, + selection ? { type: "selected", mode: session.mode, selection } : { type: "cancelled" }, + null, + ), + ); + } + + private finish( + session: ElementSelectorSession, + outcome: ElementSelectorOutcome, + cleanup: "clear" | "destroy" | null, + ): boolean { + if (this.current !== session) return false; + session.stopPolling?.(); + session.stopPolling = undefined; + if (session.timeoutId !== undefined) { + this.runtime.cancelTimeout(session.timeoutId); + session.timeoutId = undefined; + } + this.current = null; + if (cleanup) this.runtime[cleanup](session.webview, session.token); + session.onFinish(outcome); + return true; + } +} + +export function createElementSelectorController( + runtime: ElementSelectorRuntime = browserSelectorRuntime(), +): ElementSelectorController { + return new ElementSelectorOwner(runtime); +} diff --git a/packages/app/src/git/diff-document/native-hit-testing.test.ts b/packages/app/src/git/diff-document/native-hit-testing.test.ts index 2b0b27890e..6cf4216ecc 100644 --- a/packages/app/src/git/diff-document/native-hit-testing.test.ts +++ b/packages/app/src/git/diff-document/native-hit-testing.test.ts @@ -22,6 +22,24 @@ describe("native diff body-coordinate hit testing", () => { } }); + it("keeps a later expanded file interactive after an earlier body grows", () => { + const model = build("unified"); + const target = model.rows.find( + (row) => row.kind === "line" && row.fileIndex === 1 && row.cells[0]?.type === "add", + )!; + const file = model.files[1]!; + + const hit = hitTestDiffBodyPoint({ + model, + file, + locationX: file.gutterWidth + 18, + locationY: target.top - file.bodyTop + 2, + horizontalOffset: 0, + }); + + expect(hit?.kind === "cell" ? hit.position.fileIndex : null).toBe(1); + }); + it("applies the touched file's retained horizontal offset", () => { const model = build("unified"); const row = model.rows.find( diff --git a/packages/app/src/hooks/use-sidebar-pins.ts b/packages/app/src/hooks/use-sidebar-pins.ts index 2b6e2f53a7..7b88990812 100644 --- a/packages/app/src/hooks/use-sidebar-pins.ts +++ b/packages/app/src/hooks/use-sidebar-pins.ts @@ -22,6 +22,18 @@ export interface PinnedSidebarGroups { unpinnedProjects: SidebarProjectEntry[]; } +function projectWithoutPinnedWorkspaces( + project: SidebarProjectEntry, + pinnedWorkspaceKeys: ReadonlySet, +): SidebarProjectEntry { + const workspaces = project.workspaces.filter( + (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), + ); + // Keep the project even when every chat moved to Pinned. The project row owns + // its settings and new-workspace actions; chats are not its ownership boundary. + return workspaces.length === project.workspaces.length ? project : { ...project, workspaces }; +} + function buildPinnedSidebarKeys( projects: SidebarProjectEntry[], workspaceMaps: ReadonlyMap>, @@ -113,19 +125,12 @@ export function splitPinnedSidebarGroups(input: { const unpinnedProjects: SidebarProjectEntry[] = []; for (const project of projects) { - const remainingWorkspaces: SidebarWorkspacePlacement[] = []; for (const workspace of project.workspaces) { if (pinnedWorkspaceKeySet.has(workspace.workspaceKey)) { pinnedChats.push(workspace); - } else { - remainingWorkspaces.push(workspace); } } - unpinnedProjects.push( - remainingWorkspaces.length === project.workspaces.length - ? project - : { ...project, workspaces: remainingWorkspaces }, - ); + unpinnedProjects.push(projectWithoutPinnedWorkspaces(project, pinnedWorkspaceKeySet)); } pinnedChats.sort((a, b) => diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 59664bfeea..4484a57857 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -155,6 +155,11 @@ describe("translation resources", () => { expect(es.settings.project.scripts.title).toBe("Scripts"); }); + it("uses the Russian term for continuing a session in copied commands", () => { + expect(ru.workspace.tabs.menu.copyResumeCommand).toBe("Копировать команду продолжения"); + expect(ru.workspace.tabs.toasts.resumeCommandCopiedLabel).toBe("команда продолжения"); + }); + it("keeps model count labels spaced around the count", () => { expect(ar.modelSelector.modelCountPlural).toBe("{{count}} نماذج"); expect(es.modelSelector.modelCountPlural).toBe("{{count}} modelos"); diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c6c6188a0d..d0cb4f6849 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -579,7 +579,7 @@ export const ru: TranslationResources = { }, menu: { openFor: "Открыть меню для {{label}}", - copyResumeCommand: "Скопировать команду возобновления", + copyResumeCommand: "Копировать команду продолжения", copyAgentId: "Скопировать идентификатор агента", copyTerminalId: "Скопировать идентификатор терминала", copyFilePath: "Скопировать путь к файлу", @@ -626,7 +626,7 @@ export const ru: TranslationResources = { copyFailed: "Не удалось скопировать", agentIdCopiedLabel: "ID агента", terminalIdCopiedLabel: "Идентификатор терминала", - resumeCommandCopiedLabel: "команда возобновления", + resumeCommandCopiedLabel: "команда продолжения", filePathCopiedLabel: "Путь к файлу", resumeIdUnavailable: "ID возобновления недоступен", resumeCommandUnavailable: "Команда возобновления недоступна", diff --git a/packages/app/src/lib/overlay-root.test.tsx b/packages/app/src/lib/overlay-root.test.tsx index 396345fa1f..6671cd50f9 100644 --- a/packages/app/src/lib/overlay-root.test.tsx +++ b/packages/app/src/lib/overlay-root.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useWebOverlayRegistration } from "./overlay-root"; +import { dispatchTopWebOverlayKeyDown, useWebOverlayRegistration } from "./overlay-root"; describe("useWebOverlayRegistration", () => { let opener: HTMLButtonElement; @@ -82,4 +82,10 @@ describe("useWebOverlayRegistration", () => { expect(openerFocus).toHaveBeenCalled(); }); + + it("leaves IME composition keys with the focused editor", () => { + const event = new KeyboardEvent("keydown", { key: "Process", bubbles: true }); + expect(dispatchTopWebOverlayKeyDown(event)).toBe(false); + expect(event.defaultPrevented).toBe(false); + }); }); diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 2c220bafac..60cbaa30e3 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -160,6 +160,11 @@ function handleWebOverlayFocus(event: FocusEvent): void { * The window listener below is the fallback for keys nobody routes through the shortcut engine. */ export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean { + // Composition keys belong to the active text field. A modal must not turn an + // IME candidate confirmation into an overlay shortcut before the browser has + // committed the composed text. + if (event.isComposing || event.key === "Process") return false; + const top = getTopWebOverlay(); const scope = top?.getScope(); if (!top || !scope) return false; diff --git a/packages/app/src/polyfills/crypto.test.ts b/packages/app/src/polyfills/crypto.test.ts new file mode 100644 index 0000000000..0cd631732f --- /dev/null +++ b/packages/app/src/polyfills/crypto.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("expo-crypto", () => ({ + getRandomValues: (array: T): T => array, +})); + +import { installRuntimeCrypto } from "./crypto"; + +describe("installRuntimeCrypto", () => { + it("installs UUID support through the supplied native random source", () => { + const target: Record = {}; + const requestedLengths: number[] = []; + + installRuntimeCrypto(target, { + getRandomValues: (array) => { + if (array) { + requestedLengths.push(array.byteLength); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(0xab); + } + return array; + }, + }); + + const uuid = (target.crypto as Crypto).randomUUID(); + expect(uuid).toBe("abababab-abab-4bab-abab-abababababab"); + expect(requestedLengths).toEqual([16]); + }); +}); diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts index f498281fb1..d714ac2a02 100644 --- a/packages/app/src/polyfills/crypto.ts +++ b/packages/app/src/polyfills/crypto.ts @@ -1,9 +1,21 @@ import * as ExpoCrypto from "expo-crypto"; import { installCryptoPolyfills, type CryptoPolyfillTarget } from "./install-crypto-polyfills"; +interface NativeRandomSource { + getRandomValues(array: T): T; +} + +export function installRuntimeCrypto( + target: CryptoPolyfillTarget, + source: NativeRandomSource, +): void { + installCryptoPolyfills(target, { + expoGetRandomValues: (array) => source.getRandomValues(array), + }); +} export function polyfillCrypto(): void { - installCryptoPolyfills(globalThis as unknown as CryptoPolyfillTarget, { - expoGetRandomValues: (array: T): T => + installRuntimeCrypto(globalThis as unknown as CryptoPolyfillTarget, { + getRandomValues: (array: T): T => ExpoCrypto.getRandomValues( array as unknown as Parameters[0], ) as unknown as T, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3d2dc99dbd..5d034323ad 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -4022,11 +4022,6 @@ function WorkspaceScreenContent({ ) : null} {shouldRenderDesktopPaneFallback ? ( - // The splits path renders tab rows inside WorkspacePanelContent, which is what - // provides NewTabLauncherProvider. This fallback row sits outside it, and its - // new-tab menu content mounts eagerly, so without its own provider every - // non-compact native layout (tablets, foldables in landscape) crashed on mount - // with "NewTabLauncherProvider is required" (#3750). { }); }); + test("keeps a new project's own root when it has not created a workspace", () => { + const result = buildProjects({ + hosts: [ + { + serverId: "host-a", + serverName: "Host A", + isOnline: true, + projects: [descriptor("prj_a", "local-a", "/a/new-project")], + workspaces: [], + }, + ], + }); + + expect(result.projects[0]?.hosts[0]?.repoRoot).toBe("/a/new-project"); + }); + test("looks up a grouped project by host-local identity", () => { const project = buildProjects({ hosts: [ diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index 563e79b0d3..83f11d5932 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -89,9 +89,9 @@ interface HostGroup { workspaces: WorkspaceDescriptor[]; customIconRevision?: string | null; iconRevision?: string; - // Repo root for a project parent that has no workspaces yet. Without it the - // host's repoRoot resolves to "" and the project reads as non-editable. - fallbackRepoRoot: string; + // The project record exists before its first workspace. Keep its editable + // root at the project boundary instead of deriving it from child rows. + projectRoot: string; } interface ProjectGroup { @@ -126,8 +126,11 @@ function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] { }); } -function resolveHostRepoRoot(group: HostGroup): string { - return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot; +function resolveHostRepoRoot(input: { + projectRoot: string; + workspaces: readonly WorkspaceDescriptor[]; +}): string { + return input.workspaces[0]?.projectRootPath || input.projectRoot; } function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary { @@ -227,7 +230,7 @@ function addHostProjects( workspaces: [], customIconRevision: placement.customIconRevision, iconRevision: placement.iconRevision, - fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "", + projectRoot: repoRootByProjectId.get(projectId) ?? "", }); } } diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 37f3d57332..1a7bddd312 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -104,6 +104,18 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem { export type AgentLsResult = ListResult; +function daemonConnectionFailure(host: string, cause: unknown): CommandError { + const reason = cause instanceof Error ? cause.message : String(cause); + return { + code: "DAEMON_NOT_RUNNING", + message: `Cannot reach the daemon at ${host}: ${reason}`, + details: [ + "Start a local daemon with: paseo daemon start", + "To use another daemon, pass --host or set PASEO_HOST.", + ].join("\n"), + }; +} + export interface AgentLsOptions extends CommandOptions { /** -a: Include archived agents */ all?: boolean; @@ -176,14 +188,7 @@ export async function runLsCommand( try { client = await connectToDaemon({ host: options.host }); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: - "Start the daemon with: paseo daemon start\nFor a remote daemon, pass --host or set PASEO_HOST.", - }; - throw error; + throw daemonConnectionFailure(host, err); } try { diff --git a/packages/cli/tests/04-agent-ls.test.ts b/packages/cli/tests/04-agent-ls.test.ts index 586f5e221a..883411ab72 100644 --- a/packages/cli/tests/04-agent-ls.test.ts +++ b/packages/cli/tests/04-agent-ls.test.ts @@ -72,8 +72,11 @@ try { output.toLowerCase().includes("connect") || output.toLowerCase().includes("cannot"); assert(hasError, "error message should mention connection issue"); - assert(output.includes("--host "), "error message should mention --host"); - assert(output.includes("PASEO_HOST"), "error message should mention PASEO_HOST"); + assert.match( + output, + /--host .*PASEO_HOST/s, + "the recovery message should explain both remote connection inputs", + ); console.log("✓ paseo ls handles daemon not running\n"); } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 95b6bacccf..1d6499b15a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -4,7 +4,7 @@ "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", - "license": "AGPL-3.0-or-later", + "license": "Apache-2.0", "author": { "name": "Mohamed Boudra", "email": "hello@moboudra.com" diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index cf4a6d3c66..67777b0ed9 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -216,6 +216,34 @@ function logDesktopDaemonLifecycle(message: string, details?: Record, + home: string, +): DesktopDaemonStatus { + const local = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; + const reachable = payload.connectedDaemon === "reachable"; + const processAlive = local === "running"; + const stalledProcess = local === "unresponsive"; + let status: DesktopDaemonState = "stopped"; + if (reachable || processAlive) { + status = "running"; + } else if (stalledProcess) { + status = "errored"; + } + return { + serverId: typeof payload.serverId === "string" ? payload.serverId : "", + status, + listen: typeof payload.listen === "string" ? payload.listen : null, + hostname: + status === "running" && typeof payload.hostname === "string" ? payload.hostname : null, + pid: (processAlive || stalledProcess) && typeof payload.pid === "number" ? payload.pid : null, + home, + version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, + desktopManaged: payload.desktopManaged === true, + error: null, + }; +} + function resolveDesktopAppVersion(): string { if (app.isPackaged) { return app.getVersion(); @@ -248,32 +276,7 @@ export async function resolveDesktopDaemonStatus(): Promise string, unknown >; - const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; - const connectedDaemon = - typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed"; - const hasRunningLocalProcess = localDaemon === "running"; - const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive"; - const desktopManaged = payload.desktopManaged === true; - const apiReachable = connectedDaemon === "reachable"; - let status: DesktopDaemonState = "stopped"; - if (apiReachable || hasRunningLocalProcess) { - status = "running"; - } else if (localDaemon === "unresponsive") { - status = "errored"; - } - - return { - serverId: typeof payload.serverId === "string" ? payload.serverId : "", - status, - listen: typeof payload.listen === "string" ? payload.listen : null, - hostname: - status === "running" && typeof payload.hostname === "string" ? payload.hostname : null, - pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null, - home, - version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, - desktopManaged, - error: null, - }; + return statusFromDaemonProbe(payload, home); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage }); diff --git a/packages/desktop/src/features/browser-capture.test.ts b/packages/desktop/src/features/browser-capture.test.ts new file mode 100644 index 0000000000..d14c7734ee --- /dev/null +++ b/packages/desktop/src/features/browser-capture.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createBrowserCaptureService, + type BrowserCaptureGuest, + type BrowserCaptureImage, +} from "./browser-capture.js"; + +function image(dataUrl = "data:image/png;base64,capture"): BrowserCaptureImage { + return { isEmpty: () => false, toDataURL: () => dataUrl }; +} + +function harness(guest: BrowserCaptureGuest | null = null) { + const clipboard = { write: vi.fn(), writeImage: vi.fn(), writeText: vi.fn() }; + const decodeImage = vi.fn(() => image("data:image/png;base64,clipboard")); + const warn = vi.fn(); + return { + clipboard, + decodeImage, + warn, + service: createBrowserCaptureService({ + findGuest: () => guest, + decodeImage, + clipboard, + warn, + }), + }; +} + +describe("browser capture service", () => { + it("validates and rounds guest-relative bounds before capture", async () => { + const capturePage = vi.fn(async () => image()); + const { service } = harness({ isDestroyed: () => false, capturePage }); + + await expect( + service.capture({ + browserId: "browser-1", + hostWebContentsId: 42, + rect: { x: -2.4, y: 8.6, width: 20.2, height: 10.8 }, + }), + ).resolves.toBe("data:image/png;base64,capture"); + expect(capturePage).toHaveBeenCalledWith({ x: 0, y: 9, width: 20, height: 11 }); + }); + + it("rejects invalid or unavailable captures without touching the guest", async () => { + const capturePage = vi.fn(async () => image()); + const { service } = harness({ isDestroyed: () => false, capturePage }); + + await expect( + service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }), + ).resolves.toBeNull(); + expect(capturePage).not.toHaveBeenCalled(); + }); + + it("writes text and a decoded image to the clipboard atomically", () => { + const { service, clipboard } = harness(); + expect(service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" })).toBe( + true, + ); + expect(clipboard.write).toHaveBeenCalledWith({ + text: "button", + image: expect.any(Object), + }); + expect(clipboard.writeText).not.toHaveBeenCalled(); + expect(clipboard.writeImage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/desktop/src/features/browser-capture.ts b/packages/desktop/src/features/browser-capture.ts new file mode 100644 index 0000000000..792567d424 --- /dev/null +++ b/packages/desktop/src/features/browser-capture.ts @@ -0,0 +1,109 @@ +export interface BrowserCaptureImage { + isEmpty(): boolean; + toDataURL(): string; +} + +export interface BrowserCaptureGuest { + isDestroyed(): boolean; + capturePage(rect: BrowserCaptureRect): Promise; +} + +export interface BrowserCaptureRect { + x: number; + y: number; + width: number; + height: number; +} + +interface BrowserCaptureClipboard { + write(input: { text: string; image: TImage }): void; + writeImage(image: TImage): void; + writeText(text: string): void; +} + +interface BrowserCaptureDependencies { + findGuest(browserId: string, hostWebContentsId: number): BrowserCaptureGuest | null; + decodeImage(dataUrl: string): TImage; + clipboard: BrowserCaptureClipboard; + warn(event: "capture-failed" | "image-decode-failed", details: Record): void; +} + +export interface BrowserCaptureService { + capture(input: { + browserId: unknown; + hostWebContentsId: number; + rect: unknown; + }): Promise; + copy(payload: unknown): boolean; +} + +function captureRect(value: unknown): BrowserCaptureRect | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const coordinates = [record.x, record.y, record.width, record.height]; + if ( + !coordinates.every( + (coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate), + ) + ) { + return null; + } + const [x, y, width, height] = coordinates as number[]; + if (width <= 0 || height <= 0) return null; + return { + x: Math.max(0, Math.round(x)), + y: Math.max(0, Math.round(y)), + width: Math.round(width), + height: Math.round(height), + }; +} + +function copyPayload(value: unknown): { text: string | null; imageDataUrl: string | null } { + if (!value || typeof value !== "object") return { text: null, imageDataUrl: null }; + const record = value as Record; + return { + text: typeof record.text === "string" && record.text.length > 0 ? record.text : null, + imageDataUrl: + typeof record.imageDataUrl === "string" && record.imageDataUrl.startsWith("data:image") + ? record.imageDataUrl + : null, + }; +} + +export function createBrowserCaptureService( + dependencies: BrowserCaptureDependencies, +): BrowserCaptureService { + return { + async capture({ browserId, hostWebContentsId, rect }) { + if (typeof browserId !== "string" || browserId.trim().length === 0) return null; + const guest = dependencies.findGuest(browserId, hostWebContentsId); + const bounds = captureRect(rect); + if (!guest || guest.isDestroyed() || !bounds) return null; + try { + const image = await guest.capturePage(bounds); + return image.isEmpty() ? null : image.toDataURL(); + } catch (error) { + dependencies.warn("capture-failed", { browserId, error }); + return null; + } + }, + + copy(payload) { + const { text, imageDataUrl } = copyPayload(payload); + let image: TImage | null = null; + if (imageDataUrl) { + try { + const decoded = dependencies.decodeImage(imageDataUrl); + if (!decoded.isEmpty()) image = decoded; + } catch (error) { + dependencies.warn("image-decode-failed", { error }); + } + } + if (text && image) dependencies.clipboard.write({ text, image }); + else if (image) dependencies.clipboard.writeImage(image); + else if (text) dependencies.clipboard.writeText(text); + else return false; + return true; + }, + }; +} diff --git a/packages/desktop/src/features/opener.test.ts b/packages/desktop/src/features/opener.test.ts index b6a3ac1e74..5cb0ba5833 100644 --- a/packages/desktop/src/features/opener.test.ts +++ b/packages/desktop/src/features/opener.test.ts @@ -1,53 +1,39 @@ -import { ipcMain, shell } from "electron"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener"; - -vi.mock("electron", () => ({ - ipcMain: { handle: vi.fn() }, - shell: { openExternal: vi.fn() }, -})); - -function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise { - registerOpenerHandlers(); - const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => { - return channel === "paseo:opener:openUrl"; - })?.[1]; - if (typeof handler !== "function") { - throw new Error("open URL handler was not registered"); - } - return handler as (_event: unknown, url: unknown) => Promise; -} +import { createExternalUrlOpener } from "./opener"; describe("desktop opener", () => { - beforeEach(() => { - vi.mocked(ipcMain.handle).mockReset(); - vi.mocked(shell.openExternal).mockReset(); + it("passes a canonical web URL to its external owner", async () => { + const opened: string[] = []; + const open = createExternalUrlOpener({ + open: async (url) => { + opened.push(url); + }, + }); + + await open("https://example.com/docs#install"); + + expect(opened).toEqual(["https://example.com/docs#install"]); }); - it("allows only http and https external URLs", () => { - expect(isAllowedExternalUrl("https://example.com/path")).toBe(true); - expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true); - expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false); - expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false); - expect(isAllowedExternalUrl("paseo://settings")).toBe(false); - expect(isAllowedExternalUrl("/relative/path")).toBe(false); - expect(isAllowedExternalUrl(null)).toBe(false); - }); + it("does not hand non-web or relative URLs to the external owner", async () => { + const opened: string[] = []; + const open = createExternalUrlOpener({ + open: async (url) => { + opened.push(url); + }, + }); - it("opens allowed URLs through Electron shell", async () => { - const handler = getRegisteredOpenUrlHandler(); + for (const input of [ + "file:///private/data", + "javascript:alert(1)", + "paseo://settings", + "/docs", + null, + ]) { + await expect(open(input)).rejects.toThrow("Only HTTP(S) URLs can open externally."); + } - await handler({}, "https://example.com"); - - expect(shell.openExternal).toHaveBeenCalledWith("https://example.com"); - }); - - it("rejects blocked URLs before invoking Electron shell", async () => { - const handler = getRegisteredOpenUrlHandler(); - - await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL"); - - expect(shell.openExternal).not.toHaveBeenCalled(); + expect(opened).toEqual([]); }); }); diff --git a/packages/desktop/src/features/opener.ts b/packages/desktop/src/features/opener.ts index 7085df5ba3..1a4edb360f 100644 --- a/packages/desktop/src/features/opener.ts +++ b/packages/desktop/src/features/opener.ts @@ -1,25 +1,21 @@ -import { shell, ipcMain } from "electron"; - -const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]); - -export function isAllowedExternalUrl(value: unknown): value is string { - if (typeof value !== "string") { - return false; - } - - try { - const url = new URL(value); - return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol); - } catch { - return false; - } +interface ExternalUrlOwner { + open(url: string): Promise; } -export function registerOpenerHandlers(): void { - ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => { - if (!isAllowedExternalUrl(url)) { - throw new Error("Unsupported external URL"); +const EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); + +const asExternalUrl = (input: unknown): URL | undefined => { + if (typeof input !== "string" || !URL.canParse(input)) return undefined; + const candidate = new URL(input); + return EXTERNAL_PROTOCOLS.has(candidate.protocol) ? candidate : undefined; +}; + +export function createExternalUrlOpener(owner: ExternalUrlOwner) { + return async (candidate: unknown): Promise => { + const url = asExternalUrl(candidate); + if (url === undefined) { + throw new Error("Only HTTP(S) URLs can open externally."); } - await shell.openExternal(url); - }); + return owner.open(url.href); + }; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 6ef3dc14a5..b0610565ca 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -22,6 +22,7 @@ import { protocol, screen, session, + shell, webContents, } from "electron"; import { registerDaemonManager } from "./daemon/daemon-manager.js"; @@ -47,7 +48,8 @@ import { registerNotificationHandlers, ensureNotificationCenterRegistration, } from "./features/notifications.js"; -import { registerOpenerHandlers } from "./features/opener.js"; +import { createExternalUrlOpener } from "./features/opener.js"; +import { createBrowserCaptureService } from "./features/browser-capture.js"; import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js"; import { resolveAppIconPath } from "./features/stamped-icon.js"; import { setupApplicationMenu } from "./features/menu.js"; @@ -77,7 +79,11 @@ import { readLegacyPaseoBrowserIds, } from "./features/browser-profile.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; -import { PendingOpenProjectStore } from "./pending-open-project-store.js"; +import { + createDesktopWindowOwner, + type DesktopWindowOwner, + type OwnedDesktopWindow, +} from "./window/desktop-window-owner.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js"; import { @@ -346,7 +352,7 @@ let pendingAgentNavigation = parseAgentDeepLinkFromArgv(process.argv); // webContents id, so deep-linked windows (second-instance launches, the // in-app "Open in new window" action) land on the right project without // racing a global. -const pendingOpenProjectStore = new PendingOpenProjectStore(); +let desktopWindowOwner: DesktopWindowOwner; if (PASEO_DEBUG) { log.info("[open-project] argv:", process.argv); @@ -358,7 +364,7 @@ if (PASEO_DEBUG) { // a race where the push event arrives before React registers its listener. ipcMain.handle("paseo:get-pending-open-project", (event) => { const webContentsId = event.sender.id; - const result = pendingOpenProjectStore.take(webContentsId); + const result = desktopWindowOwner.takePendingProject(webContentsId); log.info("[open-project] renderer requested pending path:", { webContentsId, pendingPath: result, @@ -370,39 +376,6 @@ ipcMain.handle("paseo:agent-navigation:ready", (event) => { return agentNavigationInbox.windowReady(event.sender.id); }); -function normalizeBrowserCaptureRect( - rect: unknown, -): { x: number; y: number; width: number; height: number } | null { - if (!rect || typeof rect !== "object") { - return null; - } - const candidate = rect as Record; - const x = candidate.x; - const y = candidate.y; - const width = candidate.width; - const height = candidate.height; - if ( - typeof x !== "number" || - typeof y !== "number" || - typeof width !== "number" || - typeof height !== "number" || - !Number.isFinite(x) || - !Number.isFinite(y) || - !Number.isFinite(width) || - !Number.isFinite(height) || - width <= 0 || - height <= 0 - ) { - return null; - } - return { - x: Math.max(0, Math.round(x)), - y: Math.max(0, Math.round(y)), - width: Math.round(width), - height: Math.round(height), - }; -} - ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) => { const input = readAttachedBrowserInput(rawInput); if (!input) { @@ -543,77 +516,20 @@ ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds }); }); -ipcMain.handle( - "paseo:browser:capture-element", - async (event, browserId: unknown, rect: unknown) => { - if (typeof browserId !== "string" || browserId.trim().length === 0) { - return null; - } - const contents = getPaseoBrowserWebContentsForHostWindow(browserId, event.sender.id); - if (!contents || contents.isDestroyed()) { - return null; - } - const captureRect = normalizeBrowserCaptureRect(rect); - if (!captureRect) { - return null; - } - try { - // capturePage expects an integer rect in CSS pixels relative to the - // guest viewport, which matches getBoundingClientRect() on the page. - const image = await contents.capturePage(captureRect); - if (image.isEmpty()) { - return null; - } - return image.toDataURL(); - } catch (error) { - log.warn("[browser-capture] capture-element.failed", { - browserId, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } - }, +const browserCapture = createBrowserCaptureService({ + findGuest: getPaseoBrowserWebContentsForHostWindow, + decodeImage: (dataUrl) => nativeImage.createFromDataURL(dataUrl), + clipboard, + warn: (event, details) => log.warn(`[browser-capture] ${event}`, details), +}); + +ipcMain.handle("paseo:browser:capture-element", (event, browserId: unknown, rect: unknown) => + browserCapture.capture({ browserId, hostWebContentsId: event.sender.id, rect }), ); -ipcMain.handle("paseo:browser:copy-element", (_event, payload: unknown): boolean => { - if (!payload || typeof payload !== "object") { - return false; - } - const { text, imageDataUrl } = payload as { text?: unknown; imageDataUrl?: unknown }; - const copyText = typeof text === "string" && text.length > 0 ? text : null; - - // Resolve the image first so we can write the clipboard exactly once and - // avoid flashing an intermediate text-only state. - let image: Electron.NativeImage | null = null; - if (typeof imageDataUrl === "string" && imageDataUrl.startsWith("data:image")) { - try { - const candidate = nativeImage.createFromDataURL(imageDataUrl); - if (!candidate.isEmpty()) { - image = candidate; - } - } catch (error) { - log.warn("[browser-capture] copy-element.image-failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Writing from the main process avoids the renderer's navigator.clipboard - // NotAllowedError, which fires when focus is inside the guest . - if (copyText && image) { - clipboard.write({ text: copyText, image }); - return true; - } - if (image) { - clipboard.writeImage(image); - return true; - } - if (copyText) { - clipboard.writeText(copyText); - return true; - } - return false; -}); +ipcMain.handle("paseo:browser:copy-element", (_event, payload: unknown) => + browserCapture.copy(payload), +); protocol.registerSchemesAsPrivileged([ { @@ -727,8 +643,9 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] { async function createWindow( options: { initialRoute?: string | null; - pendingOpenProjectPath?: string | null; restoreWindowState?: boolean; + onCreated?: (webContentsId: number) => void; + onClosed?: (webContentsId: number) => void; } = {}, ): Promise { const iconPath = await getEffectiveAppIconPath(); @@ -768,14 +685,14 @@ async function createWindow( applyDesktopWindowChromeMode({ win: mainWindow, mode: DESKTOP_WINDOW_CHROME_MODE }); const webContentsId = mainWindow.webContents.id; - pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath); + options.onCreated?.(webContentsId); mainWindow.webContents.on("did-start-navigation", (_event, _url, isSameDocument, isMainFrame) => { if (isMainFrame && !isSameDocument) { agentNavigationInbox.windowLoading(webContentsId); } }); mainWindow.on("closed", () => { - pendingOpenProjectStore.delete(webContentsId); + options.onClosed?.(webContentsId); agentNavigationInbox.removeWindow(webContentsId); unregisterPaseoBrowserHost(webContentsId); browserKeyboard.detachHost(webContentsId); @@ -851,51 +768,44 @@ async function createWindow( return mainWindow; } +function ownedDesktopWindow(win: BrowserWindow): OwnedDesktopWindow { + return { + webContentsId: win.webContents.id, + isDestroyed: () => win.isDestroyed(), + isVisible: () => win.isVisible(), + isMinimized: () => win.isMinimized(), + restore: () => win.restore(), + show: () => win.show(), + focus: () => win.focus(), + sendAgent: (target) => win.webContents.send("paseo:event:open-agent", target), + }; +} + +desktopWindowOwner = createDesktopWindowOwner({ + async create(input) { + const win = await createWindow({ + initialRoute: input.initialRoute, + restoreWindowState: input.restoreWindowState, + onCreated: input.onCreated, + onClosed: input.onClosed, + }); + return ownedDesktopWindow(win); + }, + windows: () => BrowserWindow.getAllWindows().map(ownedDesktopWindow), + focusedWindow: () => { + const win = BrowserWindow.getFocusedWindow(); + if (!win) return null; + return ownedDesktopWindow(win); + }, + agentRoute: buildAgentDeepLinkRoute, + deliverAgent: (webContentsId, target) => + agentNavigationInbox.deliverOrQueue(webContentsId, target), +}); + // --------------------------------------------------------------------------- // App lifecycle // --------------------------------------------------------------------------- -let agentNavigationWindowCreation: Promise | null = null; - -function focusExistingWindowOnAgent(target: AgentDeepLinkTarget): void { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = - BrowserWindow.getFocusedWindow() ?? windows.find((window) => window.isVisible()) ?? windows[0]; - if (!mainWindow || mainWindow.isDestroyed()) { - if (!agentNavigationWindowCreation) { - const creation = createWindow({ - initialRoute: buildAgentDeepLinkRoute(target), - restoreWindowState: true, - }); - agentNavigationWindowCreation = creation; - void creation - .catch((error) => log.error("[window] failed to create window for agent link", error)) - .finally(() => { - if (agentNavigationWindowCreation === creation) { - agentNavigationWindowCreation = null; - } - }); - return; - } - - void agentNavigationWindowCreation - .then(() => focusExistingWindowOnAgent(target)) - .catch((error) => log.error("[window] failed to deliver queued agent link", error)); - return; - } - - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.show(); - mainWindow.focus(); - - const deliverable = agentNavigationInbox.deliverOrQueue(mainWindow.webContents.id, target); - if (deliverable) { - mainWindow.webContents.send("paseo:event:open-agent", deliverable); - } -} - function receiveAgentDeepLink(input: string): void { const target = parseAgentDeepLink(input); if (!target) { @@ -903,7 +813,9 @@ function receiveAgentDeepLink(input: string): void { } if (bootstrapIsComplete) { - focusExistingWindowOnAgent(target); + void desktopWindowOwner + .openOrFocusAgent(target) + .catch((error) => log.error("[window] failed to route agent link", error)); return; } @@ -913,7 +825,9 @@ function receiveAgentDeepLink(input: string): void { return undefined; } pendingAgentNavigation = null; - focusExistingWindowOnAgent(target); + void desktopWindowOwner + .openOrFocusAgent(target) + .catch((error) => log.error("[window] failed to route queued agent link", error)); return undefined; }); } @@ -938,7 +852,9 @@ function setupSingleInstanceLock(): boolean { app.on("second-instance", (_event, commandLine) => { const agentTarget = parseAgentDeepLinkFromArgv(commandLine); if (agentTarget) { - void bootstrapComplete.then(() => focusExistingWindowOnAgent(agentTarget)); + void bootstrapComplete + .then(() => desktopWindowOwner.openOrFocusAgent(agentTarget)) + .catch((error) => log.error("[window] failed to route second-instance agent link", error)); return; } @@ -952,7 +868,7 @@ function setupSingleInstanceLock(): boolean { // window rather than focusing the existing one. Wait for bootstrap (not just // app.whenReady) so the protocol + IPC handlers exist before the window loads. void bootstrapComplete - .then(() => createWindow({ pendingOpenProjectPath: openProjectPath })) + .then(() => desktopWindowOwner.openAdditional({ pendingProjectPath: openProjectPath })) .catch((error) => { log.error("[window] failed to create window from second-instance", error); }); @@ -1016,7 +932,7 @@ async function bootstrap(): Promise { await applyAppIcon(); setupApplicationMenu({ onNewWindow: () => { - void createWindow().catch((error) => { + void desktopWindowOwner.openAdditional().catch((error) => { log.error("[window] failed to create window from menu", error); }); }, @@ -1026,7 +942,8 @@ async function bootstrap(): Promise { registerWindowManager({ mode: DESKTOP_WINDOW_CHROME_MODE }); registerDialogHandlers(); registerNotificationHandlers(); - registerOpenerHandlers(); + const openExternalUrl = createExternalUrlOpener({ open: shell.openExternal }); + ipcMain.handle("paseo:opener:openUrl", (_event, value: unknown) => openExternalUrl(value)); registerEditorTargetHandlers(); registerBrowserAutomationIpc(); @@ -1037,18 +954,17 @@ async function bootstrap(): Promise { options && typeof options === "object" && "pendingOpenProjectPath" in options ? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath : null; - await createWindow({ - pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null, + await desktopWindowOwner.openAdditional({ + pendingProjectPath: typeof pendingPath === "string" ? pendingPath : null, }); }); // The first window of the session restores and persists saved geometry. const initialAgentNavigation = pendingAgentNavigation; pendingAgentNavigation = null; - await createWindow({ + await desktopWindowOwner.openPrimary({ initialRoute: initialAgentNavigation ? buildAgentDeepLinkRoute(initialAgentNavigation) : null, - pendingOpenProjectPath, - restoreWindowState: true, + pendingProjectPath: pendingOpenProjectPath, }); pendingOpenProjectPath = null; @@ -1060,13 +976,13 @@ async function bootstrap(): Promise { if (pendingAgentNavigation) { const target = pendingAgentNavigation; pendingAgentNavigation = null; - focusExistingWindowOnAgent(target); + await desktopWindowOwner.openOrFocusAgent(target); } - app.on("activate", async () => { - if (BrowserWindow.getAllWindows().length === 0) { - await createWindow({ restoreWindowState: true }); - } + app.on("activate", () => { + void desktopWindowOwner.restoreWhenActivated().catch((error) => { + console.error("Failed to restore a desktop window after activation", error); + }); }); } diff --git a/packages/desktop/src/window/desktop-window-owner.test.ts b/packages/desktop/src/window/desktop-window-owner.test.ts new file mode 100644 index 0000000000..fdf8c8cb41 --- /dev/null +++ b/packages/desktop/src/window/desktop-window-owner.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { createDesktopWindowOwner, type OwnedDesktopWindow } from "./desktop-window-owner.js"; + +interface Target { + id: string; +} + +function harness() { + const windows: OwnedDesktopWindow[] = []; + const launches: Array<{ initialRoute: string | null; restoreWindowState: boolean }> = []; + const sent: Target[] = []; + let nextId = 1; + let focused: OwnedDesktopWindow | null = null; + let closeWindow = (_id: number) => {}; + const owner = createDesktopWindowOwner({ + async create(input) { + launches.push({ + initialRoute: input.initialRoute, + restoreWindowState: input.restoreWindowState, + }); + const id = nextId++; + closeWindow = input.onClosed; + const window: OwnedDesktopWindow = { + webContentsId: id, + isDestroyed: () => false, + isVisible: () => true, + isMinimized: () => false, + restore: () => {}, + show: () => {}, + focus: () => {}, + sendAgent: (target) => sent.push(target), + }; + windows.push(window); + input.onCreated(id); + return window; + }, + windows: () => windows, + focusedWindow: () => focused, + agentRoute: (target) => `/agent/${target.id}`, + deliverAgent: (_id, target) => target, + }); + return { + owner, + launches, + sent, + windows, + setFocused: (window: OwnedDesktopWindow) => { + focused = window; + }, + close: (id: number) => closeWindow(id), + }; +} + +describe("desktop window owner", () => { + it("restores only primary launches and routes pending projects per window", async () => { + const h = harness(); + await h.owner.openPrimary({ pendingProjectPath: " /project/a " }); + await h.owner.openAdditional({ pendingProjectPath: "/project/b" }); + expect(h.launches).toEqual([ + { initialRoute: null, restoreWindowState: true }, + { initialRoute: null, restoreWindowState: false }, + ]); + expect(h.owner.takePendingProject(1)).toBe("/project/a"); + expect(h.owner.takePendingProject(2)).toBe("/project/b"); + }); + + it("focuses an existing window and delivers agent routing through its inbox", async () => { + const h = harness(); + await h.owner.openPrimary(); + h.setFocused(h.windows[0]); + await h.owner.openOrFocusAgent({ id: "agent-7" }); + expect(h.launches).toHaveLength(1); + expect(h.sent).toEqual([{ id: "agent-7" }]); + }); + + it("removes pending routing state when a window closes", async () => { + const h = harness(); + await h.owner.openAdditional({ pendingProjectPath: "/project/a" }); + h.close(1); + expect(h.owner.takePendingProject(1)).toBeNull(); + }); +}); diff --git a/packages/desktop/src/window/desktop-window-owner.ts b/packages/desktop/src/window/desktop-window-owner.ts new file mode 100644 index 0000000000..2d326ce0eb --- /dev/null +++ b/packages/desktop/src/window/desktop-window-owner.ts @@ -0,0 +1,100 @@ +import { PendingOpenProjectStore } from "../pending-open-project-store.js"; + +export interface OwnedDesktopWindow { + webContentsId: number; + isDestroyed(): boolean; + isVisible(): boolean; + isMinimized(): boolean; + restore(): void; + show(): void; + focus(): void; + sendAgent(target: TAgentTarget): void; +} + +interface DesktopWindowOwnerPort { + create(input: { + initialRoute: string | null; + restoreWindowState: boolean; + onCreated(webContentsId: number): void; + onClosed(webContentsId: number): void; + }): Promise>; + windows(): OwnedDesktopWindow[]; + focusedWindow(): OwnedDesktopWindow | null; + agentRoute(target: TAgentTarget): string; + deliverAgent(webContentsId: number, target: TAgentTarget): TAgentTarget | null; +} + +export interface DesktopWindowOwner { + openPrimary(input?: { + initialRoute?: string | null; + pendingProjectPath?: string | null; + }): Promise; + openAdditional(input?: { pendingProjectPath?: string | null }): Promise; + openOrFocusAgent(target: TAgentTarget): Promise; + restoreWhenActivated(): Promise; + takePendingProject(webContentsId: number): string | null; +} + +export function createDesktopWindowOwner( + port: DesktopWindowOwnerPort, +): DesktopWindowOwner { + const pendingProjects = new PendingOpenProjectStore(); + let agentWindowCreation: Promise | null = null; + + const open = async (input: { + initialRoute: string | null; + pendingProjectPath: string | null; + restoreWindowState: boolean; + }): Promise => { + await port.create({ + initialRoute: input.initialRoute, + restoreWindowState: input.restoreWindowState, + onCreated: (webContentsId) => pendingProjects.set(webContentsId, input.pendingProjectPath), + onClosed: (webContentsId) => pendingProjects.delete(webContentsId), + }); + }; + + const owner: DesktopWindowOwner = { + openPrimary: (input = {}) => + open({ + initialRoute: input.initialRoute ?? null, + pendingProjectPath: input.pendingProjectPath ?? null, + restoreWindowState: true, + }), + openAdditional: (input = {}) => + open({ + initialRoute: null, + pendingProjectPath: input.pendingProjectPath ?? null, + restoreWindowState: false, + }), + async openOrFocusAgent(target) { + const windows = port.windows(); + const window = + port.focusedWindow() ?? windows.find((candidate) => candidate.isVisible()) ?? windows[0]; + if (!window || window.isDestroyed()) { + if (!agentWindowCreation) { + agentWindowCreation = owner + .openPrimary({ initialRoute: port.agentRoute(target) }) + .finally(() => { + agentWindowCreation = null; + }); + await agentWindowCreation; + return; + } + await agentWindowCreation; + await owner.openOrFocusAgent(target); + return; + } + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + const deliverable = port.deliverAgent(window.webContentsId, target); + if (deliverable) window.sendAgent(deliverable); + }, + async restoreWhenActivated() { + if (port.windows().length === 0) await owner.openPrimary(); + }, + takePendingProject: (webContentsId) => pendingProjects.take(webContentsId), + }; + return owner; +} diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 0139cc13eb..5f7308cc10 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -117,7 +117,7 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { const HANDSHAKE_RETRY_MS = 1000; const MAX_PENDING_SENDS = 200; -const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008; +const REHANDSHAKE_REJECTION_CODE = 1008; const ENCRYPTED_PAYLOAD_OVERHEAD_BYTES = 40; export function base64EncryptedWireByteLength(plaintextBytes: number): number { @@ -503,33 +503,27 @@ export class EncryptedChannel { private async handleDaemonRehello(message: E2EEHelloMessage): Promise { if (!this.options.daemonKeyPair) return; const clientPublicKey = importPublicKey(message.key); - const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey); + const retryKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey); + if (!keysEqual(retryKey, this.sharedKey)) return this.rejectKeyRotation(); + await this.sendReadyForRetry(); + } - // If it's the same client key (handshake retry), re-send - // "ready" but do not re-key. Re-keying here would desync - // the channel and cause decrypt failures. - if (keysEqual(nextSharedKey, this.sharedKey)) { - await this.transport.send( - JSON.stringify({ - type: "e2ee_ready", - ...(this.options.binaryCiphertext - ? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities } - : {}), - } satisfies E2EEReadyMessage), - ); - return; - } - - // A different key on an already-open encrypted channel is not an - // authenticated reconnect. Close and require a fresh transport instead of - // allowing the relay to switch this channel to an attacker-chosen key. - this.state = "closed"; - this.transport.close( - REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE, - REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON, + private async sendReadyForRetry(): Promise { + await this.transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(this.options.binaryCiphertext + ? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities } + : {}), + } satisfies E2EEReadyMessage), ); } + private rejectKeyRotation(): void { + this.state = "closed"; + this.transport.close(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON); + } + close(code = 1000, reason = "Normal closure"): void { this.state = "closed"; this.transport.close(code, reason); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 7ddcc5b34b..3b560e5d53 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1632,7 +1632,7 @@ export class AgentManager { await registry.upsert(archivedRecord); - await this.archiveNativeSessionBestEffort(record.provider, record.persistence); + await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); if (this.agents.has(record.id)) { this.notifyAgentState(record.id); @@ -1919,7 +1919,7 @@ export class AgentManager { const nextRecord = buildArchivedAgentRecord(record, { archivedAt }); await registry.upsert(nextRecord); - await this.archiveNativeSessionBestEffort(record.provider, record.persistence); + await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); if (this.agents.has(agentId)) { this.notifyAgentState(agentId); @@ -1946,7 +1946,7 @@ export class AgentManager { return false; } - await this.unarchiveNativeSession(record.provider, record.persistence); + await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); await registry.upsert({ ...record, @@ -4896,15 +4896,22 @@ export class AgentManager { return client; } - async archiveNativeSessionBestEffort( + private async syncNativeArchiveState( provider: AgentProvider, persistence: AgentPersistenceHandle | null | undefined, + state: "archive" | "restore", ): Promise { if (!persistence) return; const client = this.clients.get(provider); - if (!client?.archiveNativeSession) return; + const sync = + state === "archive" ? client?.archiveNativeSession : client?.unarchiveNativeSession; + if (!sync) return; + if (state === "restore") { + await sync.call(client, persistence); + return; + } try { - await client.archiveNativeSession(persistence); + await sync.call(client, persistence); } catch (error) { this.logger.warn( { error, provider, sessionId: persistence.sessionId }, @@ -4913,16 +4920,6 @@ export class AgentManager { } } - private async unarchiveNativeSession( - provider: AgentProvider, - persistence: AgentPersistenceHandle | null | undefined, - ): Promise { - if (!persistence) return; - const client = this.clients.get(provider); - if (!client?.unarchiveNativeSession) return; - await client.unarchiveNativeSession(persistence); - } - private requireAgent(id: string): LiveManagedAgent { const normalizedId = validateAgentId(id, "requireAgent"); const agent = this.agents.get(normalizedId); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 99728efc5f..2541124156 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -48,30 +48,25 @@ import { const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647; const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000; -const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; +const PROVIDER_REFRESH_DEADLINE_ENV = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global"; -// Provider refresh probes can be slow on cold starts (e.g. Copilot's first -// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). -// Allow operators to bump the ceiling via env var without rebuilding. -function resolveRefreshTimeoutMs(option: number | undefined): number { - if ( - typeof option === "number" && - Number.isSafeInteger(option) && - option > 0 && - option <= MAX_REFRESH_TIMEOUT_MS - ) { - return option; - } - const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR]; - if (fromEnv) { - // Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate. - const parsed = Number(fromEnv); - if (Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_REFRESH_TIMEOUT_MS) { - return parsed; - } - } - return DEFAULT_REFRESH_TIMEOUT_MS; +function validRefreshDeadline(value: unknown): number | undefined { + return typeof value === "number" && + Number.isSafeInteger(value) && + value > 0 && + value <= MAX_REFRESH_TIMEOUT_MS + ? value + : undefined; +} + +function providerRefreshDeadline(configured: number | undefined): number { + const explicit = validRefreshDeadline(configured); + if (explicit !== undefined) return explicit; + return ( + validRefreshDeadline(Number(process.env[PROVIDER_REFRESH_DEADLINE_ENV])) ?? + DEFAULT_REFRESH_TIMEOUT_MS + ); } function resolveDiagnosticTimeoutMs(option: number | undefined, refreshTimeoutMs: number): number { @@ -231,7 +226,7 @@ export class ProviderSnapshotManager { this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; - this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs); + this.refreshTimeoutMs = providerRefreshDeadline(options.refreshTimeoutMs); this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs( options.diagnosticTimeoutMs, this.refreshTimeoutMs, @@ -559,7 +554,7 @@ export class ProviderSnapshotManager { } setRefreshTimeoutMs(refreshTimeoutMs: number | undefined): void { - this.refreshTimeoutMs = resolveRefreshTimeoutMs(refreshTimeoutMs); + this.refreshTimeoutMs = providerRefreshDeadline(refreshTimeoutMs); this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs(undefined, this.refreshTimeoutMs); } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 0b108efe49..b5d3c497c1 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -2328,6 +2328,22 @@ describe("ACPAgentClient listImportableSessions", () => { expect(listSessions).toHaveBeenCalledWith({}); }); + test("stops at the requested session limit after a source-scoped page", async () => { + const listSessions = vi.fn().mockResolvedValue({ + sessions: [ + { sessionId: "s1", cwd: "/Users/moonshot", title: null, updatedAt: null }, + { sessionId: "s2", cwd: "/Users/moonshot", title: null, updatedAt: null }, + ], + nextCursor: "later", + }); + const client = makeClient({ listSessions }); + + await expect( + client.listImportableSessions({ cwd: "/Users/moonshot", limit: 1 }), + ).resolves.toHaveLength(1); + expect(listSessions).toHaveBeenCalledTimes(1); + }); + test("forwards cwd alongside the pagination cursor across pages", async () => { const listSessions = vi .fn() diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index de90f9dd90..624424a678 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -232,9 +232,6 @@ function pushACPStderrRow(rows: DiagnosticEntry[], stderrChunks: string[]): void export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, - // ACP agents can list prior sessions via `session/list`. The runtime probe in - // listImportableSessions returns nothing for agents that don't advertise the - // capability, so enabling this here only makes the daemon query them. supportsSessionListing: true, supportsDynamicModes: true, supportsMcpServers: true, @@ -245,6 +242,13 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { supportsRewindBoth: false, }; +function acpSessionListRequest(cursor: string | null | undefined, cwd: string | undefined) { + return { + ...(cursor ? { cursor } : {}), + ...(cwd ? { cwd } : {}), + }; +} + const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { fs: { readTextFile: false, @@ -1058,13 +1062,7 @@ export class ACPAgentClient implements AgentClient { let cursor: string | null | undefined; for (;;) { const page: ListSessionsResponse = await this.runACPRequest(() => - probe.connection.listSessions({ - ...(cursor ? { cursor } : {}), - // Filter by working directory at the source. Without this the agent - // returns globally-recent sessions, which the `limit` below can - // truncate before the current directory's sessions are reached. - ...(options?.cwd ? { cwd: options.cwd } : {}), - }), + probe.connection.listSessions(acpSessionListRequest(cursor, options?.cwd)), ); for (const session of page.sessions) { sessions.push({ diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 1af10b8a5a..5069c3653f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -249,6 +249,21 @@ test("launch environment participates in the Claude default mode", async () => { ).resolves.toBe("default"); }); +test("keeps Auto mode available when hosted-transport flags are explicitly disabled", async () => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { + env: { CLAUDE_CODE_USE_BEDROCK: "off", CLAUDE_CODE_USE_VERTEX: "0" }, + }, + }); + + await expect( + client.resolveDefaultModeId({ + config: { provider: "claude", cwd: process.cwd() }, + }), + ).resolves.toBe("auto"); +}); + test("allows launch env to disable inherited Bedrock transport for auto mode", async () => { const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; process.env.CLAUDE_CODE_USE_BEDROCK = "1"; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 8e88972fd7..0432ea91e8 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -916,7 +916,7 @@ function isTruthyEnvValue(value: string | undefined): boolean { ); } -function detectIneligibleAutoModeTransport(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null { +function claudeAutoModeUnavailableOn(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null { if (isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK)) { return "Bedrock"; } @@ -926,11 +926,11 @@ function detectIneligibleAutoModeTransport(env: NodeJS.ProcessEnv): "Bedrock" | return null; } -function assertClaudeAutoModeEligible(mode: PermissionMode, env: NodeJS.ProcessEnv): void { +function assertClaudeModeCanRun(mode: PermissionMode, env: NodeJS.ProcessEnv): void { if (mode !== "auto") { return; } - const transport = detectIneligibleAutoModeTransport(env); + const transport = claudeAutoModeUnavailableOn(env); if (transport === null) { return; } @@ -939,6 +939,16 @@ function assertClaudeAutoModeEligible(mode: PermissionMode, env: NodeJS.ProcessE ); } +function claudeModeCatalog(env: NodeJS.ProcessEnv): { + modes: AgentMode[]; + defaultModeId: PermissionMode; +} { + if (claudeAutoModeUnavailableOn(env)) { + return { modes: DEFAULT_MODES.filter((mode) => mode.id !== "auto"), defaultModeId: "default" }; + } + return { modes: DEFAULT_MODES, defaultModeId: "auto" }; +} + function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial { if (!isMetadata(metadata)) { return {}; @@ -975,6 +985,7 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion), ); - const modes = detectIneligibleAutoModeTransport( + const modeCatalog = claudeModeCatalog( createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), - ) - ? DEFAULT_MODES.filter((mode) => mode.id !== "auto") - : DEFAULT_MODES; + ); return { models, - modes, - defaultModeId: modes.some((mode) => mode.id === "auto") ? "auto" : "default", + ...modeCatalog, }; } @@ -1581,7 +1589,7 @@ export class ClaudeAgentClient implements AgentClient { runtimeSettings: this.runtimeSettings, overlays: [launchEnv], }); - return detectIneligibleAutoModeTransport(env) ? "default" : "auto"; + return claudeModeCatalog(env).defaultModeId; } async listFeatures(config: AgentSessionConfig): Promise { @@ -2389,7 +2397,7 @@ class ClaudeAgentSession implements AgentSession { } const normalized = isPermissionMode(modeId) ? modeId : "default"; - assertClaudeAutoModeEligible(normalized, this.buildSdkEnv()); + assertClaudeModeCanRun(normalized, this.buildSdkEnv()); const previousMode = this.currentMode; const activeQuery = await this.ensureQuery(); await activeQuery.setPermissionMode(normalized); @@ -3235,7 +3243,7 @@ class ClaudeAgentSession implements AgentSession { ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); const sdkEnv = this.buildSdkEnv(); - assertClaudeAutoModeEligible(this.currentMode, sdkEnv); + assertClaudeModeCanRun(this.currentMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug( diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index d0a9dd3dde..664af04f3f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { type Dirent, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -19,6 +19,7 @@ import { buildCodexAppServerEnv, CodexAppServerAgentClient, CodexAppServerAgentSession, + codexMicrosoftStoreBinaryCandidates, codexAppServerTurnInputFromPrompt, listCodexSkills, mapCodexPatchNotificationToToolCall, @@ -47,6 +48,40 @@ describe("mapCodexPlanUpdateToTodo", () => { }); }); +describe("Codex executable discovery", () => { + test("only considers sorted Codex Store packages", () => { + const entries = [ + { name: "Other.App_1", isDirectory: () => true }, + { name: "OpenAI.Codex_z", isDirectory: () => true }, + { name: "OpenAI.Codex_a", isDirectory: () => true }, + { name: "OpenAI.Codex_file", isDirectory: () => false }, + ] as unknown as Dirent[]; + + expect(codexMicrosoftStoreBinaryCandidates("C:\\Packages", entries)).toEqual([ + path.join( + "C:\\Packages", + "OpenAI.Codex_a", + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + "codex.exe", + ), + path.join( + "C:\\Packages", + "OpenAI.Codex_z", + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + "codex.exe", + ), + ]); + }); +}); + import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { createFakeCodexAppServer, @@ -1491,6 +1526,31 @@ describe("Codex app-server provider", () => { appServer.assertNoErrors(); }); + test("archives the persisted native thread without opening an interactive session", async () => { + const threadRequests: Array<{ method: string; params: unknown }> = []; + const appServer = createFakeCodexAppServer({ + "thread/archive": (params) => { + threadRequests.push({ method: "thread/archive", params }); + return { thread: { id: "native-thread-id" } }; + }, + }); + const provider = new CodexAppServerAgentClient(createTestLogger()); + castInternals<{ spawnAppServer: () => Promise }>( + provider, + ).spawnAppServer = async () => appServer.child; + + await provider.archiveNativeSession({ + provider: "codex", + sessionId: "persisted-thread-id", + nativeHandle: "native-thread-id", + }); + + expect(threadRequests).toEqual([ + { method: "thread/archive", params: { threadId: "native-thread-id" } }, + ]); + appServer.assertNoErrors(); + }); + test("unarchives a persisted Codex thread using sessionId when nativeHandle is absent", async () => { const threadRequests: Array<{ method: string; params: unknown }> = []; const appServer = createFakeCodexAppServer({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index f7e06cc005..b38bc69108 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -459,6 +459,27 @@ function codexMicrosoftStorePackageRoot(): string | null { return path.join(localAppData, "Packages"); } +export function codexMicrosoftStoreBinaryCandidates( + packageRoot: string, + entries: Dirent[], +): string[] { + return entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_")) + .map((entry) => + path.join( + packageRoot, + entry.name, + "LocalCache", + "Local", + "OpenAI", + "Codex", + "bin", + "codex.exe", + ), + ) + .sort(); +} + export async function findCodexMicrosoftStoreBinary(): Promise { if (process.platform !== "win32") { return null; @@ -476,22 +497,7 @@ export async function findCodexMicrosoftStoreBinary(): Promise { return null; } - const codexPackages = entries - .filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_")) - .map((entry) => entry.name) - .sort(); - - for (const packageName of codexPackages) { - const candidate = path.join( - packageRoot, - packageName, - "LocalCache", - "Local", - "OpenAI", - "Codex", - "bin", - "codex.exe", - ); + for (const candidate of codexMicrosoftStoreBinaryCandidates(packageRoot, entries)) { if (await probeExecutable(candidate)) { return candidate; } @@ -501,7 +507,9 @@ export async function findCodexMicrosoftStoreBinary(): Promise { } export async function findDefaultCodexBinary(): Promise { - return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); + const pathBinary = await findExecutable("codex"); + if (pathBinary) return pathBinary; + return await findCodexMicrosoftStoreBinary(); } async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{ @@ -915,8 +923,10 @@ function filterCodexThreadsByCwd( // falls back to process.cwd() if the field is missing, so we only match // here when the row genuinely carries a cwd string — otherwise threads // with no cwd would falsely match the daemon's own cwd. - const matchesCwd = createPathEquivalenceMatcher(cwd); - return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd)); + const belongsToWorkspace = createPathEquivalenceMatcher(cwd); + return threads.filter( + (thread) => typeof thread.cwd === "string" && belongsToWorkspace(thread.cwd), + ); } export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined { @@ -3050,6 +3060,30 @@ interface CodexSkillPromptBlock { path: string; } +function enabledCodexSkills( + entries: unknown[], +): Array<{ name: string; description: string; path: string }> { + const skillsByName = new Map(); + for (const entry of entries) { + const skillRecord = toObjectRecord(entry); + if ( + !skillRecord || + skillRecord.enabled === false || + typeof skillRecord.name !== "string" || + typeof skillRecord.path !== "string" || + skillsByName.has(skillRecord.name) + ) { + continue; + } + skillsByName.set(skillRecord.name, { + name: skillRecord.name, + description: resolveSkillDescription(skillRecord), + path: skillRecord.path, + }); + } + return Array.from(skillsByName.values()); +} + type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock; type CodexPromptInput = string | CodexPromptContentBlock[]; interface CodexTextElement { @@ -3530,28 +3564,13 @@ export class CodexAppServerAgentSession implements AgentSession { }), ); const entries = Array.isArray(response?.data) ? response.data : []; - const skillsByName = new Map(); + const allSkills: unknown[] = []; for (const entry of entries) { const entryRecord = toObjectRecord(entry); const list = Array.isArray(entryRecord?.skills) ? entryRecord.skills : []; - for (const skill of list) { - const skillRecord = toObjectRecord(skill); - if (typeof skillRecord?.name !== "string" || typeof skillRecord?.path !== "string") - continue; - // Codex skills/list returns disabled skills with enabled:false; omit them from - // slash-command surfaces so Paseo matches Codex CLI/TUI behavior. - // Missing enabled (older binaries) is treated as enabled. - if (skillRecord.enabled === false) continue; - if (!skillsByName.has(skillRecord.name)) { - skillsByName.set(skillRecord.name, { - name: skillRecord.name, - description: resolveSkillDescription(skillRecord), - path: skillRecord.path, - }); - } - } + allSkills.push(...list); } - this.cachedSkills = Array.from(skillsByName.values()); + this.cachedSkills = enabledCodexSkills(allSkills); } catch (error) { this.logger.trace( { @@ -7065,22 +7084,17 @@ export class CodexAppServerAgentClient implements AgentClient { } async archiveNativeSession(handle: AgentPersistenceHandle): Promise { - const threadId = handle.nativeHandle ?? handle.sessionId; - if (!threadId) return; - - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger); - - try { - await client.request("initialize", buildCodexAppServerInitializeParams()); - client.notify("initialized", {}); - await client.request("thread/archive", { threadId }); - } finally { - await client.dispose(); - } + await this.updateNativeThreadArchiveState(handle, "archive"); } async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.updateNativeThreadArchiveState(handle, "restore"); + } + + private async updateNativeThreadArchiveState( + handle: AgentPersistenceHandle, + state: "archive" | "restore", + ): Promise { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; @@ -7090,12 +7104,14 @@ export class CodexAppServerAgentClient implements AgentClient { try { await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); + if (state === "archive") { + await client.request("thread/archive", { threadId }); + return; + } try { await client.request("thread/unarchive", { threadId }); } catch (error) { - if (!isCodexAlreadyUnarchivedError(error, threadId)) { - throw error; - } + if (!isCodexAlreadyUnarchivedError(error, threadId)) throw error; try { await client.request("thread/read", { threadId }); } catch { diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 35a96b6db9..6ce2d4fe13 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -91,6 +91,24 @@ describe("codex tool-call mapper", () => { }); }); + it("keeps a quoted PowerShell payload intact after removing launch flags", () => { + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-wrapper-powershell-quoted-payload", + status: "running", + command: "powershell.exe -NoProfile -Command \"Get-ChildItem 'C:\\work space'\"", + cwd: "C:\\repo", + }), + ); + + expect(item.detail).toEqual({ + type: "shell", + command: "Get-ChildItem 'C:\\work space'", + cwd: "C:\\repo", + }); + }); + it("unwraps cmd wrapper arrays for commandExecution on Windows", () => { const item = expectMapped( mapCodexToolCallFromThreadItem({ diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 7884c9bc78..2f274d2d3f 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -219,37 +219,15 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [ CodexSubAgentActivityItemSchema, ]); -function maybeUnwrapShellWrapperCommand(command: string): string { +function unwrapShellCommand(command: string): string { const trimmed = command.trim(); - const unixWrapperMatch = trimmed.match( - /^(?:(?:\/[^/\s]+)*\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/, + const unix = trimmed.match(/^(?:(?:\/[^/\s]+)*\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/); + if (unix?.[1]) return stripMatchingEdgeQuotes(unix[1].trim()); + + const windows = trimmed.match( + /^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?(?:\s+-[A-Za-z]+(?:\s+[^-\s][^\s]*)?)*\s+(?:-Command|-c|\/c)\s+([\s\S]+)$/i, ); - if (unixWrapperMatch) { - const candidate = unixWrapperMatch[1]?.trim() ?? ""; - if (!candidate) { - return trimmed; - } - return stripMatchingEdgeQuotes(candidate); - } - const windowsWrapperMatch = trimmed.match( - /^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i, - ); - if (!windowsWrapperMatch) { - return trimmed; - } - const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? ""; - if (!wrappedCommand) { - return trimmed; - } - const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i); - if (!commandMatch) { - return trimmed; - } - const candidate = commandMatch[1]?.trim() ?? ""; - if (!candidate) { - return trimmed; - } - return stripMatchingEdgeQuotes(candidate); + return windows?.[1] ? stripMatchingEdgeQuotes(windows[1].trim()) : trimmed; } function stripMatchingEdgeQuotes(value: string): string { @@ -269,7 +247,7 @@ function isWindowsShellCommand(command: string): boolean { function normalizeCommandExecutionCommand(value: unknown): string | undefined { if (typeof value === "string") { - const normalized = maybeUnwrapShellWrapperCommand(value); + const normalized = unwrapShellCommand(value); return normalized.length > 0 ? normalized : undefined; } if (!Array.isArray(value)) { diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts new file mode 100644 index 0000000000..af2bdabf01 --- /dev/null +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js"; + +function decoderHarness() { + const frames: Record[] = []; + const problems: JsonlFrameProblem[] = []; + return { + frames, + problems, + decoder: new JsonlFrameDecoder({ + frame: (frame) => frames.push(frame), + problem: (problem) => problems.push(problem), + }), + }; +} + +function chunkFrames(frame: Record, chunkSize: number) { + const payload = Buffer.from(JSON.stringify(frame)); + const count = Math.ceil(payload.byteLength / chunkSize); + return Array.from({ length: count }, (_, index) => ({ + type: "rpc_chunk", + chunkId: "catalog", + index, + count, + byteLength: payload.byteLength, + data: payload.subarray(index * chunkSize, (index + 1) * chunkSize).toString("base64"), + })); +} + +describe("JsonlFrameDecoder", () => { + it("publishes ordinary frames across arbitrary stream chunks", () => { + const { decoder, frames, problems } = decoderHarness(); + decoder.write('{"type":"ready"'); + decoder.write('}\r\n{"type":"notice","value":2}\n'); + expect(frames).toEqual([{ type: "ready" }, { type: "notice", value: 2 }]); + expect(problems).toEqual([]); + }); + + it("reassembles a large ordered v2 frame", () => { + const { decoder, frames, problems } = decoderHarness(); + const frame = { type: "models", value: "x".repeat(1024 * 1024) }; + for (const chunk of chunkFrames(frame, 256 * 1024)) { + decoder.write(`${JSON.stringify(chunk)}\n`); + } + expect(frames).toEqual([frame]); + expect(problems).toEqual([]); + }); + + it("drops an out-of-order sequence before accepting the next frame", () => { + const { decoder, frames, problems } = decoderHarness(); + const chunks = chunkFrames({ type: "models", value: "x".repeat(1024 * 1024) }, 256 * 1024); + decoder.write(`${JSON.stringify(chunks[1])}\n`); + decoder.write('{"type":"ready"}\n'); + expect(frames).toEqual([{ type: "ready" }]); + expect(problems).toEqual(["out-of-order-chunk"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts new file mode 100644 index 0000000000..1662e7719c --- /dev/null +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts @@ -0,0 +1,183 @@ +const V2_FRAME_BYTES = 1024 * 1024; +const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024; +const V2_CHUNK_BYTES = 256 * 1024; +const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4; +const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES); + +export type JsonlFrameProblem = + | "invalid-json" + | "invalid-chunk" + | "invalid-base64" + | "out-of-order-chunk" + | "chunk-length-mismatch" + | "invalid-chunk-payload"; + +interface ChunkHeader { + id: string; + index: number; + count: number; + byteLength: number; + data: string; +} + +interface ChunkAssembly { + id: string; + count: number; + byteLength: number; + parts: Buffer[]; + bytes: number; +} + +export function supportsJsonlRpcProtocolV2(message: Record): boolean { + return ( + message.type === "ready" && + Array.isArray(message.supportedProtocolVersions) && + message.supportedProtocolVersions.includes(2) && + message.maxFrameBytes === V2_FRAME_BYTES && + message.maxReassembledFrameBytes === V2_REASSEMBLED_BYTES + ); +} + +function objectFrame(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function chunkHeader(frame: Record): ChunkHeader | null { + const { chunkId, index, count, byteLength, data } = frame; + if ( + typeof chunkId !== "string" || + chunkId.length === 0 || + chunkId.length > 128 || + typeof index !== "number" || + !Number.isSafeInteger(index) || + index < 0 || + typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 2 || + count > V2_MAX_CHUNKS || + index >= count || + typeof byteLength !== "number" || + !Number.isSafeInteger(byteLength) || + byteLength < V2_FRAME_BYTES || + byteLength > V2_REASSEMBLED_BYTES || + typeof data !== "string" || + data.length === 0 + ) { + return null; + } + return { id: chunkId, index, count, byteLength, data }; +} + +function chunkBytes(data: string): Buffer | null { + if (data.length > V2_BASE64_CHARS) return null; + const bytes = Buffer.from(data, "base64"); + if (bytes.byteLength > V2_CHUNK_BYTES || bytes.toString("base64") !== data) return null; + return bytes; +} + +export class JsonlFrameDecoder { + private lineBuffer = ""; + private assembly: ChunkAssembly | null = null; + + constructor( + private readonly receiver: { + frame(message: Record): void; + problem(problem: JsonlFrameProblem, detail?: unknown): void; + }, + ) {} + + write(text: string): void { + this.lineBuffer += text; + let newline = this.lineBuffer.indexOf("\n"); + while (newline !== -1) { + const line = this.lineBuffer.slice(0, newline).replace(/\r$/, ""); + this.lineBuffer = this.lineBuffer.slice(newline + 1); + if (line.trim()) this.consumeLine(line); + newline = this.lineBuffer.indexOf("\n"); + } + } + + private consumeLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + this.receiver.problem("invalid-json", { error, line }); + return; + } + const frame = objectFrame(parsed); + if (!frame) return; + if (frame.type !== "rpc_chunk") { + this.receiver.frame(frame); + return; + } + this.consumeChunk(frame); + } + + private consumeChunk(frame: Record): void { + const header = chunkHeader(frame); + if (!header) { + this.drop("invalid-chunk", frame); + return; + } + const bytes = chunkBytes(header.data); + if (!bytes) { + this.drop("invalid-base64"); + return; + } + if (!this.assembly) { + if (header.index !== 0) { + this.receiver.problem("out-of-order-chunk"); + return; + } + this.assembly = { + id: header.id, + count: header.count, + byteLength: header.byteLength, + parts: [], + bytes: 0, + }; + } + const assembly = this.assembly; + if ( + assembly.id !== header.id || + assembly.count !== header.count || + assembly.byteLength !== header.byteLength || + assembly.parts.length !== header.index + ) { + this.drop("out-of-order-chunk"); + return; + } + assembly.parts.push(bytes); + assembly.bytes += bytes.byteLength; + if (assembly.bytes > assembly.byteLength) { + this.drop("chunk-length-mismatch"); + return; + } + if (assembly.parts.length < assembly.count) return; + this.assembly = null; + if (assembly.bytes !== assembly.byteLength) { + this.receiver.problem("chunk-length-mismatch"); + return; + } + this.finishAssembly(assembly.parts); + } + + private finishAssembly(parts: Buffer[]): void { + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts)); + const frame = objectFrame(JSON.parse(text)); + if (frame) this.receiver.frame(frame); + else this.receiver.problem("invalid-chunk-payload"); + } catch (error) { + this.receiver.problem("invalid-chunk-payload", error); + } + } + + private drop(problem: JsonlFrameProblem, detail?: unknown): void { + this.assembly = null; + this.receiver.problem(problem, detail); + } +} diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index ddcbaae8a9..4daff1bd06 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -3,6 +3,8 @@ import type { Logger } from "pino"; import { spawnProcess } from "../../../utils/spawn.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; +import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js"; +export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js"; /** Default wall-clock timeout for control-plane / short RPC calls. */ export const JSONL_RPC_DEFAULT_TIMEOUT_MS = 30_000; @@ -15,11 +17,6 @@ export const JSONL_RPC_NO_TIMEOUT = null; const STDERR_BUFFER_LIMIT = 8192; const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; -const RPC_V2_MAX_FRAME_BYTES = 1024 * 1024; -const RPC_V2_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024; -const RPC_V2_CHUNK_PAYLOAD_BYTES = 256 * 1024; -const RPC_V2_MAX_ENCODED_CHUNK_LENGTH = Math.ceil(RPC_V2_CHUNK_PAYLOAD_BYTES / 3) * 4; -const RPC_V2_MAX_CHUNK_COUNT = Math.ceil(RPC_V2_MAX_REASSEMBLED_BYTES / RPC_V2_CHUNK_PAYLOAD_BYTES); export interface JsonlRpcLaunch { command: string; @@ -43,30 +40,6 @@ interface PendingRequest { timer: NodeJS.Timeout | null; } -/** - * Reassembly state for a protocol v2 chunked frame sequence. Some agents that - * speak this JSONL RPC protocol (OMP) cap single-line frames at 1 MiB and emit - * larger frames as ordered `rpc_chunk` frames; the transport reassembles them - * into the original logical frame before routing it. - */ -interface PendingChunkSequence { - chunkId: string; - count: number; - byteLength: number; - nextIndex: number; - chunks: Buffer[]; - receivedBytes: number; -} - -/** Validated metadata fields of a protocol v2 `rpc_chunk` frame. */ -interface RpcChunkMetadata { - chunkId: string; - index: number; - count: number; - byteLength: number; - data: string; -} - export interface JsonlRpcExit { code: number | null; signal: NodeJS.Signals | null; @@ -80,16 +53,6 @@ export interface JsonlRpcProcessOptions { spawn?: (launch: JsonlRpcLaunch) => ChildProcessWithoutNullStreams; } -export function supportsJsonlRpcProtocolV2(message: Record): boolean { - return ( - message.type === "ready" && - Array.isArray(message.supportedProtocolVersions) && - message.supportedProtocolVersions.includes(2) && - message.maxFrameBytes === RPC_V2_MAX_FRAME_BYTES && - message.maxReassembledFrameBytes === RPC_V2_MAX_REASSEMBLED_BYTES - ); -} - function assertChildWithPipes( child: ChildProcess, ): asserts child is ChildProcessWithoutNullStreams { @@ -117,11 +80,19 @@ export class JsonlRpcProcess { private stderrBuffer = ""; private nextRequestId = 1; private disposed = false; - private stdoutBuffer = ""; - private pendingChunks: PendingChunkSequence | null = null; + private readonly frameDecoder: JsonlFrameDecoder; constructor(private readonly options: JsonlRpcProcessOptions) { this.diagnosticName = options.diagnosticName ?? "JSONL RPC"; + this.frameDecoder = new JsonlFrameDecoder({ + frame: (message) => this.dispatchFrame(message), + problem: (problem, detail) => { + this.options.logger.warn( + { problem, detail }, + `Ignoring invalid ${this.diagnosticName} frame`, + ); + }, + }); this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch); this.child.stdout.on("data", (chunk) => { this.handleStdoutChunk(chunk.toString()); @@ -229,45 +200,7 @@ export class JsonlRpcProcess { } private handleStdoutChunk(chunk: string): void { - this.stdoutBuffer += chunk; - for (;;) { - const newlineIndex = this.stdoutBuffer.indexOf("\n"); - if (newlineIndex === -1) { - break; - } - const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, ""); - this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1); - if (line.trim()) { - this.handleLine(line); - } - } - } - - private handleLine(line: string): void { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch (error) { - this.options.logger.warn( - { error, line }, - `Ignoring non-JSON ${this.diagnosticName} stdout line`, - ); - return; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return; - } - const message = parsed as Record; - if (message.type === "rpc_chunk") { - const reassembled = this.consumeRpcChunk(message); - if (reassembled !== undefined) { - // The reassembled frame is a complete logical frame; route it through - // the same path as any other parsed line. - this.handleLine(JSON.stringify(reassembled)); - } - return; - } - this.dispatchFrame(message); + this.frameDecoder.write(chunk); } /** Route a complete logical frame to its consumer (response or subscriber). */ @@ -281,173 +214,6 @@ export class JsonlRpcProcess { } } - /** - * Reassemble a protocol v2 `rpc_chunk` frame. Returns the reconstructed - * logical frame once the final chunk of a sequence arrives, `undefined` while - * a sequence is still in progress, and drops (returning `undefined`) any - * malformed or out-of-order chunk after logging it. - */ - private consumeRpcChunk(chunk: Record): Record | undefined { - const metadata = this.parseChunkMetadata(chunk); - if (metadata === undefined) { - return undefined; - } - const bytes = this.decodeChunkPayload(metadata.data); - if (bytes === undefined) { - return undefined; - } - return this.appendChunk(metadata, bytes); - } - - /** Validate a `rpc_chunk` frame's metadata, or reset the sequence and return undefined. */ - private parseChunkMetadata(chunk: Record): RpcChunkMetadata | undefined { - const chunkId = chunk.chunkId; - const data = chunk.data; - const index = chunk.index; - const count = chunk.count; - const byteLength = chunk.byteLength; - if ( - typeof chunkId !== "string" || - chunkId.length === 0 || - chunkId.length > 128 || - typeof data !== "string" || - data.length === 0 || - typeof index !== "number" || - !Number.isSafeInteger(index) || - index < 0 || - typeof count !== "number" || - !Number.isSafeInteger(count) || - count < 2 || - count > RPC_V2_MAX_CHUNK_COUNT || - index >= count || - typeof byteLength !== "number" || - !Number.isSafeInteger(byteLength) || - byteLength < RPC_V2_MAX_FRAME_BYTES || - byteLength > RPC_V2_MAX_REASSEMBLED_BYTES - ) { - this.options.logger.warn( - { chunk }, - `Ignoring malformed ${this.diagnosticName} rpc_chunk frame`, - ); - this.pendingChunks = null; - return undefined; - } - return { chunkId, index, count, byteLength, data }; - } - - /** Decode a chunk payload, or reset the sequence and return undefined on bad base64. */ - private decodeChunkPayload(data: string): Buffer | undefined { - try { - if (data.length > RPC_V2_MAX_ENCODED_CHUNK_LENGTH) { - throw new Error("encoded payload exceeds the transport limit"); - } - const bytes = Buffer.from(data, "base64"); - if (bytes.byteLength > RPC_V2_CHUNK_PAYLOAD_BYTES || bytes.toString("base64") !== data) { - throw new Error("invalid base64 payload"); - } - return bytes; - } catch { - this.options.logger.warn({}, `Ignoring ${this.diagnosticName} rpc_chunk with invalid base64`); - this.pendingChunks = null; - return undefined; - } - } - - /** Append a chunk to the in-progress sequence, finalizing it once complete. */ - private appendChunk( - metadata: RpcChunkMetadata, - bytes: Buffer, - ): Record | undefined { - let pending = this.pendingChunks; - if (pending === null) { - if (metadata.index !== 0) { - this.options.logger.warn( - {}, - `Ignoring ${this.diagnosticName} rpc_chunk sequence that did not start at index 0`, - ); - return undefined; - } - pending = { - chunkId: metadata.chunkId, - count: metadata.count, - byteLength: metadata.byteLength, - nextIndex: 0, - chunks: [], - receivedBytes: 0, - }; - this.pendingChunks = pending; - } - - if ( - pending.chunkId !== metadata.chunkId || - pending.count !== metadata.count || - pending.byteLength !== metadata.byteLength || - pending.nextIndex !== metadata.index - ) { - this.options.logger.warn( - {}, - `Ignoring out-of-order ${this.diagnosticName} rpc_chunk; dropping sequence`, - ); - this.pendingChunks = null; - return undefined; - } - - pending.chunks.push(bytes); - pending.receivedBytes += bytes.byteLength; - pending.nextIndex += 1; - - if (pending.receivedBytes > pending.byteLength) { - this.options.logger.warn( - {}, - `Ignoring ${this.diagnosticName} rpc_chunk sequence exceeding its declared length`, - ); - this.pendingChunks = null; - return undefined; - } - if (pending.nextIndex < pending.count) { - return undefined; - } - if (pending.receivedBytes !== pending.byteLength) { - this.options.logger.warn( - {}, - `Ignoring ${this.diagnosticName} rpc_chunk sequence with a length mismatch`, - ); - this.pendingChunks = null; - return undefined; - } - - this.pendingChunks = null; - return this.finalizeChunks(pending.chunks); - } - - /** Reassemble and parse a complete chunk sequence into its logical frame. */ - private finalizeChunks(chunks: Buffer[]): Record | undefined { - let json: string; - try { - json = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); - } catch { - this.options.logger.warn( - {}, - `Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid UTF-8`, - ); - return undefined; - } - let frame: unknown; - try { - frame = JSON.parse(json); - } catch { - this.options.logger.warn( - {}, - `Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid JSON`, - ); - return undefined; - } - if (!frame || typeof frame !== "object" || Array.isArray(frame)) { - return undefined; - } - return frame as Record; - } - private handleResponse(response: JsonlRpcResponse): void { if (!response.id) { return; diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 97e01c937a..7b3f383965 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -2,12 +2,8 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; -import { - JSONL_RPC_DEFAULT_TIMEOUT_MS, - JsonlRpcProcess, - supportsJsonlRpcProtocolV2, - type JsonlRpcLaunch, -} from "../jsonl-rpc-process.js"; +import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js"; +import { establishOmpProtocol } from "./protocol-session.js"; import { buildOmpLaunch, type OmpRuntime, @@ -45,8 +41,6 @@ import { const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"]; const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands"; -/** How long to wait for OMP's startup `ready` frame before failing startup. */ -const OMP_READY_TIMEOUT_MS = 10_000; export interface OmpCliRuntimeOptions { logger: Logger; @@ -91,7 +85,7 @@ export class OmpCliRuntime implements OmpRuntime { const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined); input.signal?.addEventListener("abort", handleAbort, { once: true }); try { - await negotiateOmpProtocolV2(process, this.options.logger); + await establishOmpProtocol(process, this.options.logger); input.signal?.throwIfAborted(); return new OmpCliRuntimeSession(process, this.commandsRpcName); } catch (error) { @@ -104,60 +98,6 @@ export class OmpCliRuntime implements OmpRuntime { } } -/** - * Wait for OMP to advertise readiness and negotiate RPC protocol v2 when it is - * supported. OMP caps protocol-v1 single-line frames at 1 MiB; `get_available_models` - * (and other large payloads) can exceed that and are returned as an overflow error. - * Protocol v2 lifts the ceiling to 64 MiB by chunking oversized frames, which the - * JSONL transport reassembles. Supported OMP versions send a `ready` frame immediately - * after launch; startup fails if the process exits or never becomes ready. - */ -async function negotiateOmpProtocolV2(process: JsonlRpcProcess, logger: Logger): Promise { - const ready = await waitForOmpReadyFrame(process); - if (!supportsJsonlRpcProtocolV2(ready)) { - return; - } - const response = (await process.request( - { type: "negotiate_protocol", protocolVersion: 2 }, - JSONL_RPC_DEFAULT_TIMEOUT_MS, - )) as { protocolVersion?: unknown } | undefined; - if (response?.protocolVersion !== 2) { - throw new Error("OMP did not accept RPC protocol v2"); - } - logger.debug({}, "Negotiated OMP RPC protocol v2 (chunked frame transport)"); -} - -function waitForOmpReadyFrame(process: JsonlRpcProcess): Promise> { - return new Promise((resolve, reject) => { - let settled = false; - let unsubscribeMessage = (): void => {}; - let unsubscribeExit = (): void => {}; - let timer: NodeJS.Timeout | undefined; - const finish = (result: Record | Error): void => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - unsubscribeMessage(); - unsubscribeExit(); - if (result instanceof Error) { - reject(result); - } else { - resolve(result); - } - }; - unsubscribeMessage = process.onMessage((message) => { - if (message.type === "ready") { - finish(message); - } - }); - unsubscribeExit = process.onExit(({ error }) => finish(error)); - timer = setTimeout( - () => finish(new Error("Timed out waiting for OMP to become ready")), - OMP_READY_TIMEOUT_MS, - ); - }); -} - class OmpCliRuntimeSession implements OmpRuntimeSession { private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>(); activeBranchEntryId?: string; diff --git a/packages/server/src/server/agent/providers/omp/protocol-session.test.ts b/packages/server/src/server/agent/providers/omp/protocol-session.test.ts new file mode 100644 index 0000000000..1e8dfc43db --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/protocol-session.test.ts @@ -0,0 +1,77 @@ +import pino from "pino"; +import { describe, expect, it } from "vitest"; +import type { JsonlRpcExit } from "../jsonl-rpc-process.js"; +import { establishOmpProtocol, type OmpProtocolTransport } from "./protocol-session.js"; + +function transportHarness() { + let receiveMessage: ((message: Record) => void) | null = null; + let receiveExit: ((exit: JsonlRpcExit) => void) | null = null; + const requests: Array<{ command: Record; timeoutMs: number | null }> = []; + let response: unknown = { protocolVersion: 2 }; + const transport: OmpProtocolTransport = { + onMessage(receiver) { + receiveMessage = receiver; + return () => { + receiveMessage = null; + }; + }, + onExit(receiver) { + receiveExit = receiver; + return () => { + receiveExit = null; + }; + }, + async request(command, timeoutMs) { + requests.push({ command, timeoutMs }); + return response; + }, + }; + return { + transport, + requests, + emitReady: (frame: Record) => receiveMessage?.(frame), + setResponse: (value: unknown) => { + response = value; + }, + exitSubscribed: () => receiveExit !== null, + }; +} + +const V2_READY = { + type: "ready", + supportedProtocolVersions: [1, 2], + maxFrameBytes: 1024 * 1024, + maxReassembledFrameBytes: 64 * 1024 * 1024, +}; + +describe("establishOmpProtocol", () => { + it("owns readiness and v2 negotiation", async () => { + const harness = transportHarness(); + const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" })); + harness.emitReady(V2_READY); + await negotiation; + expect(harness.requests).toEqual([ + { + command: { type: "negotiate_protocol", protocolVersion: 2 }, + timeoutMs: 30_000, + }, + ]); + expect(harness.exitSubscribed()).toBe(false); + }); + + it("keeps protocol v1 when the ready frame has no matching capability", async () => { + const harness = transportHarness(); + const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" })); + harness.emitReady({ type: "ready" }); + await negotiation; + expect(harness.requests).toEqual([]); + }); + + it("rejects a peer that does not confirm v2", async () => { + const harness = transportHarness(); + harness.setResponse({ protocolVersion: 1 }); + const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" })); + harness.emitReady(V2_READY); + await expect(negotiation).rejects.toThrow("OMP did not accept RPC protocol v2"); + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/protocol-session.ts b/packages/server/src/server/agent/providers/omp/protocol-session.ts new file mode 100644 index 0000000000..ae2ad4b809 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/protocol-session.ts @@ -0,0 +1,53 @@ +import type { Logger } from "pino"; +import { + JSONL_RPC_DEFAULT_TIMEOUT_MS, + supportsJsonlRpcProtocolV2, + type JsonlRpcExit, +} from "../jsonl-rpc-process.js"; + +const OMP_READY_TIMEOUT_MS = 10_000; + +export interface OmpProtocolTransport { + onMessage(callback: (message: Record) => void): () => void; + onExit(callback: (exit: JsonlRpcExit) => void): () => void; + request(command: Record, timeoutMs: number | null): Promise; +} + +export async function establishOmpProtocol( + transport: OmpProtocolTransport, + logger: Logger, +): Promise { + const ready = await waitForReady(transport); + if (!supportsJsonlRpcProtocolV2(ready)) return; + const response = (await transport.request( + { type: "negotiate_protocol", protocolVersion: 2 }, + JSONL_RPC_DEFAULT_TIMEOUT_MS, + )) as { protocolVersion?: unknown } | undefined; + if (response?.protocolVersion !== 2) throw new Error("OMP did not accept RPC protocol v2"); + logger.debug({}, "Negotiated OMP RPC protocol v2 (chunked frame transport)"); +} + +function waitForReady(transport: OmpProtocolTransport): Promise> { + return new Promise((resolve, reject) => { + let settled = false; + let unsubscribeMessage = (): void => {}; + let unsubscribeExit = (): void => {}; + const timer = setTimeout( + () => finish(new Error("Timed out waiting for OMP to become ready")), + OMP_READY_TIMEOUT_MS, + ); + const finish = (result: Record | Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsubscribeMessage(); + unsubscribeExit(); + if (result instanceof Error) reject(result); + else resolve(result); + }; + unsubscribeMessage = transport.onMessage((message) => { + if (message.type === "ready") finish(message); + }); + unsubscribeExit = transport.onExit(({ error }) => finish(error)); + }); +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3d64c891e1..a3fd30f7f0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1054,9 +1054,7 @@ async function collectOpenCodeImportableSessionsFromSdk( throw new Error(`Failed to list OpenCode sessions: ${JSON.stringify(response.error)}`); } - const matchesCwd = options?.cwd ? createPathEquivalenceMatcher(options.cwd) : null; - return (response.data ?? []) - .filter((session) => !matchesCwd || matchesCwd(session.directory)) + return selectOpenCodeSessionsForWorkspace(response.data ?? [], options?.cwd) .sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left)) .slice(0, limit) .map((session) => ({ @@ -1069,6 +1067,25 @@ async function collectOpenCodeImportableSessionsFromSdk( })); } +function selectOpenCodeSessionsForWorkspace( + sessions: OpenCodePersistedSession[], + cwd: string | undefined, +): OpenCodePersistedSession[] { + if (!cwd) return sessions; + const belongsToWorkspace = createPathEquivalenceMatcher(cwd); + return sessions.filter((session) => belongsToWorkspace(session.directory)); +} + +function openCodeCatalogDirectory( + options: FetchCatalogOptions, + resolveHomeDir: () => string, +): { directory: string; needsDirectory: boolean } { + if (options.scope === "workspace") { + return { directory: options.cwd, needsDirectory: false }; + } + return { directory: resolveHomeDir(), needsDirectory: true }; +} + function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null { const normalized = title?.trim(); return normalized ? normalized : null; @@ -1519,13 +1536,10 @@ export class OpenCodeAgentClient implements AgentClient { if (!acquisition) throw new Error("OpenCode server acquisition did not complete"); context?.signal.throwIfAborted(); const { url } = acquisition.server; - const isGlobalCatalog = options.scope === "global"; + const catalogDirectory = openCodeCatalogDirectory(options, this.resolveHomeDir); + const { directory } = catalogDirectory; - // OpenCode treats the catalog directory as a workspace. The global catalog - // is not a project, so use the neutral OpenCode home instead of user home. - const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd; - - if (isGlobalCatalog) { + if (catalogDirectory.needsDirectory) { await fs.mkdir(directory, { recursive: true }); this.logger.debug( { directory }, diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 5432b913ea..39851fd167 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -30,14 +30,6 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [ ]; const DEFAULT_COMMANDS_RPC_NAME = "get_commands"; -/** - * Pi RPC timeout policy: - * - Control-plane / accept-and-stream (`prompt`, `get_state`, `abort`, …): default 30s - * - Long-running blocking LLM jobs (`compact`): no wall-clock timeout — complete on - * response, process death, or session close (`JsonlRpcProcess.failAll` / `close`). - */ -const PI_COMPACT_REQUEST_TIMEOUT_MS = JSONL_RPC_NO_TIMEOUT; - export interface PiCliRuntimeOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; @@ -128,16 +120,10 @@ class PiCliRuntimeSession implements PiRuntimeSession { } async compact(customInstructions?: string): Promise { - // Compact is a blocking LLM summarization job; Pi only returns the RPC - // response after the summary is written. A control-plane 30s timeout falsely - // fails long sessions while the real compact continues (issue #1946). - await this.request( - { - type: "compact", - ...(customInstructions ? { customInstructions } : {}), - }, - PI_COMPACT_REQUEST_TIMEOUT_MS, - ); + await this.waitForCompletion({ + type: "compact", + ...(customInstructions ? { customInstructions } : {}), + }); } async setAutoCompaction(enabled: boolean): Promise { @@ -234,6 +220,13 @@ class PiCliRuntimeSession implements PiRuntimeSession { return this.process.request(command, timeoutMs); } + private async waitForCompletion(command: PiRpcCommand): Promise { + // Pi only replies after its compaction work is durable. Its child process and + // session close paths already reject pending RPCs, so no elapsed-time failure + // is useful here. + await this.process.request(command, JSONL_RPC_NO_TIMEOUT); + } + private emit(event: PiRuntimeEvent): void { for (const subscriber of this.subscribers) { subscriber(event); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 718920d055..619bb238e0 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -119,37 +119,17 @@ export function createRequireBearerMiddleware( }; } -// Routes that authenticate via their own capability and therefore must not be -// gated a second time behind the daemon password. -const BEARER_AUTH_BYPASS_PATHS = new Set([ - // Unauthenticated liveness probe. - "/api/health", - // Guarded by a single-use download token (crypto-random UUID, 60s TTL, - // consumed on first use) that is only ever issued over the - // already-authenticated WebSocket. The token IS the capability for this - // route. Requiring the daemon password on top of it breaks browser and - // Electron downloads: those trigger the download via an anchor navigation, - // which cannot attach an `Authorization` header. The download endpoint still - // rejects requests without a valid token (400/403), so dropping the bearer - // here does not make the route unauthenticated. - "/api/files/download", - // The daemon injects its own agents' Paseo MCP connections at this endpoint - // (and connects its own per-client MCP client here). Those connections cannot - // carry the daemon password — it is only known in plaintext when set via env, - // never when set via the app — so the route authenticates them with a - // per-daemon-run capability token instead (see isAgentMcpRequestAuthorized). - // The token is injected only into local agent configs/sessions and never sent - // to remote clients, and the route still rejects callers presenting neither - // the token nor a valid daemon password, so dropping the global bearer here - // does not make the endpoint unauthenticated. - "/mcp/agents", -]); +const SELF_AUTHENTICATING_ROUTES = new Set(["/api/files/download", "/mcp/agents"]); + +function isBearerFreeRoute(path: string): boolean { + return path === "/api/health" || SELF_AUTHENTICATING_ROUTES.has(path); +} export function shouldBypassBearerAuth(method: string, path: string): boolean { if (method === "OPTIONS") { return true; } - return BEARER_AUTH_BYPASS_PATHS.has(path); + return isBearerFreeRoute(path); } /** diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 837bd7640d..66ecb02597 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -228,8 +228,8 @@ import { DaemonExecutions } from "./hub/daemon-executions.js"; import { PluginService } from "./plugins/index.js"; import { ManagedPluginSources } from "./plugins/managed-source.js"; -const MAX_MCP_DEBUG_BATCH_ITEMS = 10; -const REDACTED_LOG_VALUE = "[redacted]"; +const MCP_DEBUG_BATCH_LIMIT = 10; +const MCP_DEBUG_SECRET = "[redacted]"; const DOWNLOAD_OPEN_FLAGS = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; @@ -327,35 +327,28 @@ export function createTerminalActivityRouteHandler( }; } -function summarizeAgentMcpDebugMessage(body: unknown): Record { - if (!body || typeof body !== "object" || Array.isArray(body)) { - return { - type: body === null ? "null" : typeof body, - }; +function describeMcpRequest(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { shape: value === null ? "null" : typeof value }; } - - const record = body as Record; - const method = typeof record.method === "string" ? record.method : undefined; + const request = value as Record; return { - type: "object", - ...(typeof record.jsonrpc === "string" ? { jsonrpc: record.jsonrpc } : {}), - ...(method ? { method } : {}), - hasId: Object.prototype.hasOwnProperty.call(record, "id"), - hasParams: Object.prototype.hasOwnProperty.call(record, "params"), + shape: "request", + ...(typeof request.jsonrpc === "string" ? { jsonrpc: request.jsonrpc } : {}), + ...(typeof request.method === "string" ? { method: request.method } : {}), + hasId: "id" in request, + hasParams: "params" in request, }; } -function summarizeAgentMcpDebugBody(body: unknown): Record { - if (!Array.isArray(body)) { - return summarizeAgentMcpDebugMessage(body); - } - - const messages = body.slice(0, MAX_MCP_DEBUG_BATCH_ITEMS).map(summarizeAgentMcpDebugMessage); +function describeMcpDebugPayload(value: unknown): Record { + if (!Array.isArray(value)) return describeMcpRequest(value); + const sampled = value.slice(0, MCP_DEBUG_BATCH_LIMIT).map(describeMcpRequest); return { - type: "batch", - count: body.length, - messages, - ...(body.length > messages.length ? { omitted: body.length - messages.length } : {}), + shape: "batch", + count: value.length, + sampled, + ...(sampled.length < value.length ? { skipped: value.length - sampled.length } : {}), }; } @@ -1439,8 +1432,8 @@ export async function createPaseoDaemon( method: req.method, url: req.originalUrl, sessionId: req.header("mcp-session-id"), - authorization: req.header("authorization") ? REDACTED_LOG_VALUE : undefined, - body: summarizeAgentMcpDebugBody(req.body), + authorization: req.header("authorization") ? MCP_DEBUG_SECRET : undefined, + body: describeMcpDebugPayload(req.body), }, "Agent MCP request", ); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index dbc319c10b..72e45286da 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -786,31 +786,26 @@ async function resolveScopedPath({ root, relativePath = ".", }: ScopedPathParams): Promise { - const normalizedRoot = expandUserPath(root); - const requestedPath = resolvePathFromBase(normalizedRoot, relativePath); - const relative = path.relative(normalizedRoot, requestedPath); - - if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) { - throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); - } - - const realRoot = await fs.realpath(normalizedRoot); - + const workspacePath = expandUserPath(root); + const requestedPath = resolvePathFromBase(workspacePath, relativePath); + assertWithinWorkspace(workspacePath, requestedPath); + const canonicalRoot = await fs.realpath(workspacePath); try { - const realPath = await fs.realpath(requestedPath); - const realRelative = path.relative(realRoot, realPath); - if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) { - throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); - } - return { requestedPath, resolvedPath: realPath }; + const canonicalPath = await fs.realpath(requestedPath); + assertWithinWorkspace(canonicalRoot, canonicalPath); + return { requestedPath, resolvedPath: canonicalPath }; } catch (error) { - if (isMissingEntryError(error)) { - return { requestedPath, resolvedPath: requestedPath }; - } + if (isMissingEntryError(error)) return { requestedPath, resolvedPath: requestedPath }; throw error; } } +function assertWithinWorkspace(root: string, candidate: string): void { + const relative = path.relative(root, candidate); + if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return; + throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); +} + async function openFileForRead(filePath: string): Promise { return fs.open(filePath, READ_FILE_OPEN_FLAGS); } diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 9589ffb753..ec2d9c0e3f 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -62,6 +62,20 @@ const execFileAsync = promisify(execFile); const HUB_ORIGIN = "https://hub.test"; const SOCKET_URL = "wss://hub.test/daemon"; const REPOSITORY_ROOT = path.resolve(import.meta.dirname, "../../../../../.."); +const REMOVAL_RETRY_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]); + +async function removeDirectoryAfterWritesSettle(directory: string): Promise { + for (let retry = 0; retry < 10; retry += 1) { + try { + await rm(directory, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !REMOVAL_RETRY_CODES.has(code) || retry === 9) throw error; + await new Promise((resolve) => setTimeout(resolve, 100 * (retry + 1))); + } + } +} interface PersistedRelationship { version: number; @@ -1444,24 +1458,7 @@ export class HubRelationshipHarness { } private async removeRoot(): Promise { - // Daemon may still flush into .paseo/projects during teardown; recursive rm - // can race and hit ENOTEMPTY/EBUSY/EPERM. Observed on Linux CI as well as macOS/Windows. - const retryableCodes = new Set(["ENOTEMPTY", "EBUSY", "EPERM"]); - const attempts = 10; - for (let attempt = 1; attempt <= attempts; attempt++) { - try { - await rm(this.root, { recursive: true, force: true }); - return; - } catch (error) { - if ( - !retryableCodes.has((error as NodeJS.ErrnoException).code ?? "") || - attempt === attempts - ) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); - } - } + await removeDirectoryAfterWritesSettle(this.root); } private latestSocket(): SocketAttempt { diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts index 5ac035a21b..8b162f292b 100644 --- a/packages/server/src/server/private-files.ts +++ b/packages/server/src/server/private-files.ts @@ -5,25 +5,22 @@ import path from "node:path"; export const PRIVATE_DIRECTORY_MODE = 0o700; export const PRIVATE_FILE_MODE = 0o600; -function chmodBestEffort(targetPath: string, mode: number): void { - if (process.platform === "win32") { - return; - } - +function applyPrivateMode(target: string, mode: number): void { + if (process.platform === "win32") return; try { - chmodSync(targetPath, mode); + chmodSync(target, mode); } catch { - // Keep startup resilient if the filesystem does not support POSIX modes. + // Permissions are not portable; creation must still work on such filesystems. } } export function ensurePrivateDirectory(directoryPath: string): void { mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); - chmodBestEffort(directoryPath, PRIVATE_DIRECTORY_MODE); + applyPrivateMode(directoryPath, PRIVATE_DIRECTORY_MODE); } export function ensurePrivateFile(filePath: string): void { - chmodBestEffort(filePath, PRIVATE_FILE_MODE); + applyPrivateMode(filePath, PRIVATE_FILE_MODE); } export function writePrivateFileAtomicSync( @@ -31,16 +28,14 @@ export function writePrivateFileAtomicSync( data: string | NodeJS.ArrayBufferView, ): void { ensurePrivateDirectory(path.dirname(filePath)); - const tmpPath = path.join( - path.dirname(filePath), - `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, - ); + const parent = path.dirname(filePath); + const temporary = path.join(parent, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}`); try { - writeFileSync(tmpPath, data, { mode: PRIVATE_FILE_MODE }); - renameSync(tmpPath, filePath); + writeFileSync(temporary, data, { mode: PRIVATE_FILE_MODE }); + renameSync(temporary, filePath); ensurePrivateFile(filePath); } catch (error) { - rmSync(tmpPath, { force: true }); + rmSync(temporary, { force: true }); throw error; } } diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index 867f52dc52..75d6ed74dd 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -436,30 +436,33 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { private async readCredentials(): Promise { const credPath = join(this.claudeHome, ".credentials.json"); + const fileCredentials = await this.readCredentialFile(credPath); + return ( + fileCredentials ?? (this.platform === "darwin" ? await this.readKeychainCredential() : null) + ); + } - if (existsSync(credPath)) { - try { - const creds = ClaudeCredentialsSchema.parse( - JSON.parse(await fs.readFile(credPath, "utf8")), - ); - const oauth = creds.claudeAiOauth; - if (oauth?.accessToken) { - return { oauth: { ...oauth, accessToken: oauth.accessToken } }; - } - } catch { - // Fall through to the macOS Keychain below. - } + private async readCredentialFile(path: string): Promise { + if (!existsSync(path)) return null; + try { + return this.toCredentialRecord( + ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), + ); + } catch { + return null; } + } - if (this.platform === "darwin") { - const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials()); - const oauth = creds.success ? creds.data.claudeAiOauth : undefined; - if (oauth?.accessToken) { - return { oauth: { ...oauth, accessToken: oauth.accessToken } }; - } - } + private async readKeychainCredential(): Promise { + const parsed = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials()); + return parsed.success ? this.toCredentialRecord(parsed.data) : null; + } - return null; + private toCredentialRecord( + credentials: z.infer, + ): ClaudeCredentialRecord | null { + const oauth = credentials.claudeAiOauth; + return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null; } private async callClaudeApi(token: string): Promise { diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/services/quota-fetcher/providers/grok.ts index 33de944b79..096cda8335 100644 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ b/packages/server/src/services/quota-fetcher/providers/grok.ts @@ -67,6 +67,23 @@ export function extractGrokTokenFromAuth(auth: unknown): string | null { return null; } +function grokMonthlyCreditBalance( + response: z.infer, +): ProviderUsageBalance | null { + const limit = response.config?.monthlyLimit?.val ?? null; + const used = response.config?.used?.val ?? response.usage?.creditUsage ?? null; + if (limit === null && used === null) return null; + return { + id: "monthly_credits", + label: "Monthly credits", + used, + remaining: limit !== null && used !== null ? Math.max(0, limit - used) : null, + limit, + unit: "credits", + tone: toneFromUsedPct(usedPctOf(used, limit)), + }; +} + export class GrokQuotaProvider implements ProviderUsageFetcher { readonly providerId = "grok"; readonly displayName = "Grok"; @@ -105,25 +122,7 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { } const resp = GrokUsageResponseSchema.parse(await res.json()); - const monthlyLimit = resp.config?.monthlyLimit?.val ?? null; - // Live CLI billing uses config.used.val; older mocks used usage.creditUsage. - const creditUsage = resp.config?.used?.val ?? resp.usage?.creditUsage ?? null; - const balances: ProviderUsageBalance[] = []; - if (monthlyLimit !== null || creditUsage !== null) { - const remaining = - monthlyLimit !== null && creditUsage !== null - ? Math.max(0, monthlyLimit - creditUsage) - : null; - balances.push({ - id: "monthly_credits", - label: "Monthly credits", - used: creditUsage, - remaining, - limit: monthlyLimit, - unit: "credits", - tone: toneFromUsedPct(usedPctOf(creditUsage, monthlyLimit)), - }); - } + const balance = grokMonthlyCreditBalance(resp); return { providerId: this.providerId, @@ -131,7 +130,7 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { status: "available", planLabel: null, windows: [], - balances, + balances: balance ? [balance] : [], details: [], error: null, }; diff --git a/packages/website/src/content/alternatives/claude-desktop.md b/packages/website/src/content/alternatives/claude-desktop.md index b4f55f9fba..53a5e9a607 100644 --- a/packages/website/src/content/alternatives/claude-desktop.md +++ b/packages/website/src/content/alternatives/claude-desktop.md @@ -9,7 +9,7 @@ order: 55 Claude Desktop is Anthropic's desktop app for Claude. It includes Chat, Cowork, and Claude Code in one app. Claude Code runs in the desktop app on macOS and Windows. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0). +Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). ![Paseo desktop and mobile app](/hero-mockup.png) @@ -97,7 +97,7 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel | | Paseo | Claude Desktop | | ---------------------------- | --------------------------------------------------------------- | --------------------------------- | -| License | Open source (AGPL-3.0) | Not published as open source | +| License | Open source (Apache-2.0) | Not published as open source | | Desktop platforms | macOS, Linux, Windows | macOS, Windows | | Native mobile | iOS, Android | iOS, Android Claude apps | | Coding agents | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code | diff --git a/packages/website/src/content/alternatives/codex-app.md b/packages/website/src/content/alternatives/codex-app.md index af29184926..f01fb3cf06 100644 --- a/packages/website/src/content/alternatives/codex-app.md +++ b/packages/website/src/content/alternatives/codex-app.md @@ -9,7 +9,7 @@ order: 54 Codex App is OpenAI's desktop app for working with Codex threads in parallel. It runs on macOS and Windows, with local, worktree, and cloud modes. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0). +Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). ![Paseo desktop and mobile app](/hero-mockup.png) @@ -91,7 +91,7 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe | | Paseo | Codex App | | ---------------------------- | --------------------------------------------------------------- | ---------------------------- | -| License | Open source (AGPL-3.0) | Not published as open source | +| License | Open source (Apache-2.0) | Not published as open source | | Desktop platforms | macOS, Linux, Windows | macOS, Windows | | Native mobile | iOS, Android | No | | Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex | diff --git a/packages/website/src/content/alternatives/conductor.md b/packages/website/src/content/alternatives/conductor.md index 49ffb867f2..5996badd41 100644 --- a/packages/website/src/content/alternatives/conductor.md +++ b/packages/website/src/content/alternatives/conductor.md @@ -9,7 +9,7 @@ order: 50 Conductor is a macOS app for running Claude Code and Codex in parallel git worktrees. Closed source. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0). +Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). ![Paseo desktop and mobile app](/hero-mockup.png) @@ -80,7 +80,7 @@ Paseo's speech-to-text and text-to-speech run locally on your device. Nothing le | | Paseo | Conductor | | ---------------------------- | --------------------------------------------------------------- | ------------------ | -| License | Open source (AGPL-3.0) | Closed source | +| License | Open source (Apache-2.0) | Closed source | | Platforms | macOS, Linux, Windows | macOS only | | Native mobile | iOS, Android | — | | Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex | diff --git a/packages/website/src/content/alternatives/happy-coder.md b/packages/website/src/content/alternatives/happy-coder.md index 8af4c2f21f..22fd0d80f0 100644 --- a/packages/website/src/content/alternatives/happy-coder.md +++ b/packages/website/src/content/alternatives/happy-coder.md @@ -9,7 +9,7 @@ order: 53 Happy Coder is a mobile and web client for Claude Code and Codex. It wraps the agent CLI on your laptop and syncs sessions to phone and browser over an end-to-end encrypted relay. Open source under MIT. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0). +Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). ![Paseo desktop and mobile app](/hero-mockup.png) @@ -82,7 +82,7 @@ Paseo's speech-to-text and text-to-speech run locally on your device. Nothing le | | Paseo | Happy Coder | | ---------------------------- | --------------------------------------------------------------- | ---------------------- | -| License | Open source (AGPL-3.0) | Open source (MIT) | +| License | Open source (Apache-2.0) | Open source (MIT) | | Desktop app | macOS, Linux, Windows | — | | Native mobile | iOS, Android | iOS, Android | | Architecture | Daemon owns agent lifecycle | Wraps the agent CLI | diff --git a/packages/website/src/content/alternatives/openchamber.md b/packages/website/src/content/alternatives/openchamber.md index 6980a97d15..f94984c645 100644 --- a/packages/website/src/content/alternatives/openchamber.md +++ b/packages/website/src/content/alternatives/openchamber.md @@ -9,7 +9,7 @@ order: 52 OpenChamber is a macOS desktop app for OpenCode. Also available as a PWA. Open source under MIT. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0). +Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). ![Paseo desktop and mobile app](/hero-mockup.png) @@ -76,7 +76,7 @@ Paseo's speech-to-text and text-to-speech run locally on your device. OpenChambe | | Paseo | OpenChamber | | ---------------------------- | --------------------------------------------------------------- | ----------------- | -| License | Open source (AGPL-3.0) | Open source (MIT) | +| License | Open source (Apache-2.0) | Open source (MIT) | | Desktop platforms | macOS, Linux, Windows | macOS | | Mobile | Native iOS, Android | PWA | | Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode | diff --git a/packages/website/src/content/alternatives/opencode-desktop.md b/packages/website/src/content/alternatives/opencode-desktop.md index eac0e40d09..774828b9dd 100644 --- a/packages/website/src/content/alternatives/opencode-desktop.md +++ b/packages/website/src/content/alternatives/opencode-desktop.md @@ -9,7 +9,7 @@ order: 56 OpenCode Desktop is the desktop app for OpenCode. It is available in beta for macOS, Windows, and Linux. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0). +Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). ![Paseo desktop and mobile app](/hero-mockup.png) @@ -87,7 +87,7 @@ OpenCode supports multi-session work on the same project. If you want worktree i Both tools are open source. -Paseo is AGPL-3.0 and runs your agents through a daemon you control. OpenCode is open source and says it does not store your code or context data by default. OpenCode share links are public when you create them. +Paseo is Apache-2.0 and runs your agents through a daemon you control. OpenCode is open source and says it does not store your code or context data by default. OpenCode share links are public when you create them. ## Voice @@ -97,7 +97,7 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe | | Paseo | OpenCode Desktop | | ---------------------------- | --------------------------------------------------------------- | ------------------------------- | -| License | Open source (AGPL-3.0) | Open source | +| License | Open source (Apache-2.0) | Open source | | Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | | Native mobile | iOS, Android | No | | Agent harnesses | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode | diff --git a/packages/website/src/content/alternatives/superset.md b/packages/website/src/content/alternatives/superset.md index 43c440cb10..b3226fd3f1 100644 --- a/packages/website/src/content/alternatives/superset.md +++ b/packages/website/src/content/alternatives/superset.md @@ -9,7 +9,7 @@ order: 51 Superset is a macOS desktop app for running CLI coding agents in parallel git worktrees. Source-available under the Elastic License 2.0. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0). +Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). ![Paseo desktop and mobile app](/hero-mockup.png) @@ -19,7 +19,7 @@ Pick Superset if you prefer a terminal-first interface where agents live inside Pick Paseo if you want: -- An OSI-approved open source license (AGPL-3.0) +- An OSI-approved open source license (Apache-2.0) - Linux or Windows - A native mobile app - No login wall @@ -28,7 +28,7 @@ Pick Paseo if you want: ## License -Paseo is open source under AGPL-3.0. Audit it, fork it, redistribute it. +Paseo is open source under Apache-2.0. Audit it, fork it, redistribute it. Superset is source-available under the Elastic License 2.0. The source is on GitHub, but the license restricts hosting it as a service and limits redistribution. @@ -98,7 +98,7 @@ Superset is free for one seat with local workspaces only. Team features and sync | | Paseo | Superset | | ---------------------------- | ----------------------------------------------------- | -------------------------------------- | -| License | Open source (AGPL-3.0) | Source-available (Elastic License 2.0) | +| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | | Platforms | macOS, Linux, Windows | macOS only | | Native mobile | iOS, Android | — | | Login required | No | Yes (Superset account) | diff --git a/packages/website/src/llms.ts b/packages/website/src/llms.ts index dec2b73451..34c401dc63 100644 --- a/packages/website/src/llms.ts +++ b/packages/website/src/llms.ts @@ -14,7 +14,7 @@ A self-hosted daemon manages agent lifecycle, exposes a WebSocket API, and ships Paseo supports every major coding agent: Claude Code, Codex, GitHub Copilot, OpenCode, Cursor, Gemini, Cline, Goose, Amp, Aider, and 30+ others. Each agent runs as its own process; Paseo handles I/O, persistence, git worktree isolation, schedules, and skills. -Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: AGPL-3.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh. +Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: Apache-2.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh. `; function docLine(doc: Doc): string { diff --git a/packages/website/src/routes/privacy.tsx b/packages/website/src/routes/privacy.tsx index ffe49c3fe3..38443f71e2 100644 --- a/packages/website/src/routes/privacy.tsx +++ b/packages/website/src/routes/privacy.tsx @@ -40,9 +40,8 @@ function Privacy() {
  • Session IDs
  • - All messages between your phone and daemon are end-to-end encrypted with - XSalsa20-Poly1305. The relay cannot read your messages, see your code, or decrypt your - traffic. + Your phone and daemon encrypt relay payloads end-to-end with NaCl box encryption + (Curve25519 plus XSalsa20-Poly1305). The relay only carries the ciphertext.

    diff --git a/public-docs/security.md b/public-docs/security.md index bfe0993bdd..982b956829 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -34,8 +34,8 @@ Relay is off on new installations. When you pair a device from `paseo`, `paseo d 1. The daemon generates a persistent ECDH keypair and stores it in `$PASEO_HOME/daemon-keypair.json` 2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key 3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent - messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). +4. Each side combines its Curve25519 key with the peer public key, then uses the resulting NaCl + `box` channel for XSalsa20-Poly1305 protected payloads. The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. @@ -44,8 +44,8 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot: - **Send commands**, Without your phone's private key, it cannot complete the handshake -- **Read your traffic**, All messages are encrypted with XSalsa20-Poly1305 (NaCl `box`) after the handshake -- **Forge messages**, NaCl `box` provides authenticated encryption; tampered messages are rejected +- **Read your traffic**, Only the paired endpoints can open the NaCl `box` payloads +- **Forge messages**, The authenticated cipher rejects modified payloads - **Replay old messages**, Each session derives fresh encryption keys ### Trust model