packages feed

pms-infra-agent-socket-0.0.1.0: src/PMS/Infra/Agent/Socket/DS/Core.hs

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-}

module PMS.Infra.Agent.Socket.DS.Core where

import System.IO
import Control.Monad.Logger
import Control.Monad.IO.Class
import Control.Lens
import Control.Monad.Reader
import Control.Concurrent.Async
import qualified Control.Concurrent.STM as STM
import Data.Conduit
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Control.Monad.Except
import qualified Control.Exception.Safe as E
import System.Exit
import Data.Aeson
import qualified Data.ByteString as BS

import qualified PMS.Domain.Model.DM.Type as DM
import qualified PMS.Domain.Model.DM.Constant as DM
import qualified PMS.Domain.Model.DS.Utility as DM

import PMS.Infra.Agent.Socket.DM.Type
import PMS.Infra.Agent.Socket.DM.Constant
import PMS.Infra.Agent.Socket.DS.Utility


-- |
--
app :: AppContext ()
app = do
  $logDebugS DM._LOGTAG "app called."
  runConduit pipeline
  where
    pipeline :: ConduitM () Void AppContext ()
    pipeline = src .| cmd2task .| sink

---------------------------------------------------------------------------------
-- |
--
src :: ConduitT () DM.AgentSocketCommand AppContext ()
src = lift go >>= yield >> src
  where
    go :: AppContext DM.AgentSocketCommand
    go = do
      queue <- view DM.agentSocketQueueDomainData <$> lift ask
      liftIO $ STM.atomically $ STM.readTQueue queue

---------------------------------------------------------------------------------
-- |
--
cmd2task :: ConduitT DM.AgentSocketCommand (IOTask ()) AppContext ()
cmd2task = await >>= \case
  Just cmd -> flip catchError (errHdl cmd) $ do
    lift (go cmd) >>= yield >> cmd2task
  Nothing -> do
    $logWarnS DM._LOGTAG "cmd2task: await returns nothing. skip."
    cmd2task

  where
    errHdl :: DM.AgentSocketCommand -> String -> ConduitT DM.AgentSocketCommand (IOTask ()) AppContext ()
    errHdl socketCmd msg = do
      let jsonrpc = DM.getJsonRpcAgentSocketCommand socketCmd
      $logWarnS DM._LOGTAG $ T.pack $ "cmd2task: exception occurred. skip. " ++ msg
      lift $ errorToolsCallResponse jsonrpc $ "cmd2task: exception occurred. skip. " ++ msg
      cmd2task

    go :: DM.AgentSocketCommand -> AppContext (IOTask ())
    go (DM.AgentSocketEchoCommand      dat) = genEchoTask dat
    go (DM.AgentSocketOpenCommand      dat) = genSocketOpenTask dat      -- CR-04
    go (DM.AgentSocketCloseCommand     dat) = genSocketCloseTask dat     -- CR-05
    go (DM.AgentSocketReadCommand      dat) = genSocketReadTask dat      -- CR-06
    go (DM.AgentSocketReadByteCommand  dat) = genSocketReadByteTask dat  -- CR-06
    go (DM.AgentSocketWriteCommand     dat) = genSocketWriteTask dat     -- CR-07
    go (DM.AgentSocketWriteByteCommand dat) = genSocketWriteByteTask dat -- CR-07

---------------------------------------------------------------------------------
-- |
--
sink :: ConduitT (IOTask ()) Void AppContext ()
sink = await >>= \case
  Just req -> flip catchError errHdl $ do
    lift (go req) >> sink
  Nothing -> do
    $logWarnS DM._LOGTAG "sink: await returns nothing. skip."
    sink

  where
    errHdl :: String -> ConduitT (IOTask ()) Void AppContext ()
    errHdl msg = do
      $logWarnS DM._LOGTAG $ T.pack $ "sink: exception occurred. skip. " ++ msg
      sink

    go :: (IO ()) -> AppContext ()
    go task = do
      $logDebugS DM._LOGTAG "sink: start async."
      _ <- liftIOE $ async task
      $logDebugS DM._LOGTAG "sink: end async."
      return ()

---------------------------------------------------------------------------------
-- |
--
genEchoTask :: DM.AgentSocketEchoCommandData -> AppContext (IOTask ())
genEchoTask dat = do
  resQ <- view DM.responseQueueDomainData <$> lift ask
  let val = dat^.DM.valueAgentSocketEchoCommandData
  $logDebugS DM._LOGTAG $ T.pack $ "genEchoTask: echo : " ++ val
  return $ echoTask resQ dat val

-- |
--
echoTask :: STM.TQueue DM.McpResponse -> DM.AgentSocketEchoCommandData -> String -> IOTask ()
echoTask resQ cmdDat val = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Socket.DS.Core.echoTask run. " ++ val
  DM.toolsCallResponse resQ (cmdDat^.DM.jsonrpcAgentSocketEchoCommandData) ExitSuccess val ""
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.echoTask end."
  where
    errHdl :: E.SomeException -> IO ()
    errHdl e = DM.toolsCallResponse resQ (cmdDat^.DM.jsonrpcAgentSocketEchoCommandData) (ExitFailure 1) "" (show e)

---------------------------------------------------------------------------------
-- | CR-04 / CR-12: agent-socket-open implementation with sandboxNetworks check.
-- The sandboxNetworks check is performed here in the gen function (AppContext),
-- consistent with how genProcRunTask performs the allowedCmds check.
-- Unix Domain Socket connections bypass the sandboxNetworks check.
genSocketOpenTask :: DM.AgentSocketOpenCommandData -> AppContext (IOTask ())
genSocketOpenTask cmdDat = do
  let argsBS = DM.unRawJsonByteString $ cmdDat^.DM.argumentsAgentSocketOpenCommandData
  resQ            <- view DM.responseQueueDomainData <$> lift ask
  handleTMVar     <- view handleAppData <$> ask
  sandboxNetworks <- view DM.sandboxNetworksDomainData <$> lift ask
  argsDat         <- liftEither $ eitherDecode argsBS
  let mFile = argsDat^.fileSocketOpenToolParams
      mHost = argsDat^.hostSocketOpenToolParams
      mPort = argsDat^.portSocketOpenToolParams
  -- CR-12: sandboxNetworks check for TCP connections only; Unix Domain Socket is exempt
  case (mFile, mHost) of
    (Nothing, Just host) -> liftIO (DM.checkSandboxNetworks sandboxNetworks host) >>= liftEither
    _                    -> return ()
  return $ socketOpenTask cmdDat resQ handleTMVar mFile mHost mPort

-- | Unix Domain Socket connection.
socketOpenTask :: DM.AgentSocketOpenCommandData
               -> STM.TQueue DM.McpResponse
               -> STM.TMVar (Maybe Handle)
               -> Maybe String
               -> Maybe String
               -> Maybe String
               -> IOTask ()
socketOpenTask cmdDat resQ handleTMVar (Just path) _ _ = flip E.catchAny errHdl $ do
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketOpenTask (unix) start."
  STM.atomically (STM.takeTMVar handleTMVar) >>= \case
    Just h -> do
      STM.atomically $ STM.putTMVar handleTMVar (Just h)
      hPutStrLn stderr "[ERROR] socketOpenTask: socket is already connected."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "socket is already connected."
    Nothing -> flip E.catchAny connErrHdl $ do
      hdl <- createUnixSocketHandle path
      STM.atomically $ STM.putTMVar handleTMVar (Just hdl)
      DM.toolsCallResponse resQ jsonRpc ExitSuccess ("socket connected to unix://" ++ path) ""
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketOpenTask (unix) end."
  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentSocketOpenCommandData
    errHdl :: E.SomeException -> IO ()
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)
    connErrHdl :: E.SomeException -> IO ()
    connErrHdl e = do
      STM.atomically $ STM.putTMVar handleTMVar Nothing
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)

-- | TCP connection. sandboxNetworks check is already done in genSocketOpenTask.
socketOpenTask cmdDat resQ handleTMVar Nothing (Just host) (Just port) = flip E.catchAny errHdl $ do
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketOpenTask (tcp) start."
  STM.atomically (STM.takeTMVar handleTMVar) >>= \case
    Just h -> do
      STM.atomically $ STM.putTMVar handleTMVar (Just h)
      hPutStrLn stderr "[ERROR] socketOpenTask: socket is already connected."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "socket is already connected."
    Nothing -> flip E.catchAny connErrHdl $ do
      hdl <- createSocketHandle host port
      STM.atomically $ STM.putTMVar handleTMVar (Just hdl)
      DM.toolsCallResponse resQ jsonRpc ExitSuccess ("socket connected to " ++ host ++ ":" ++ port) ""
  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketOpenTask (tcp) end."
  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentSocketOpenCommandData
    errHdl :: E.SomeException -> IO ()
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)
    connErrHdl :: E.SomeException -> IO ()
    connErrHdl e = do
      STM.atomically $ STM.putTMVar handleTMVar Nothing
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)

-- | Invalid arguments (all other patterns).
socketOpenTask cmdDat resQ _ _ _ _ = do
  hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Socket.DS.Core.socketOpenTask: invalid arguments."
  DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "invalid arguments: specify file, or host and port."
  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentSocketOpenCommandData

---------------------------------------------------------------------------------
-- |
--
genSocketCloseTask :: DM.AgentSocketCloseCommandData -> AppContext (IOTask ())
genSocketCloseTask dat = do
  $logDebugS DM._LOGTAG $ T.pack $ "genSocketCloseTask called. "
  handleTMVar <- view handleAppData <$> ask
  resQ <- view DM.responseQueueDomainData <$> lift ask
  return $ socketCloseTask dat resQ handleTMVar

-- |
--
socketCloseTask :: DM.AgentSocketCloseCommandData
                -> STM.TQueue DM.McpResponse
                -> STM.TMVar (Maybe Handle)
                -> IOTask ()
socketCloseTask cmdDat resQ handleTMVar = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketCloseTask run. "
  let jsonRpc = cmdDat^.DM.jsonrpcAgentSocketCloseCommandData

  STM.atomically (STM.swapTMVar handleTMVar Nothing) >>= \case
    Nothing -> do
      hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Socket.DS.Core.socketCloseTask: socket is not connected."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "socket is not connected."
    Just hdl -> do
      hClose hdl
      DM.toolsCallResponse resQ jsonRpc ExitSuccess "" "socket is closed."
      hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketCloseTask closeSocket."

  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketCloseTask end."

  where
    errHdl :: E.SomeException -> IO ()
    errHdl e = DM.toolsCallResponse resQ (cmdDat^.DM.jsonrpcAgentSocketCloseCommandData) (ExitFailure 1) "" (show e)

-- |
--
genSocketReadTask :: DM.AgentSocketReadCommandData -> AppContext (IOTask ())
genSocketReadTask cmdData = do
  let argsBS = DM.unRawJsonByteString $ cmdData^.DM.argumentsAgentSocketReadCommandData
  resQ        <- view DM.responseQueueDomainData <$> lift ask
  handleTMVar <- view handleAppData <$> ask
  argsDat     <- liftEither $ eitherDecode argsBS
  let size       = argsDat^.lengthSocketReadToolParams
      actualSize = min size _READ_BUFFER_SIZE
  return $ socketReadTask cmdData resQ handleTMVar actualSize

-- |
--
socketReadTask :: DM.AgentSocketReadCommandData
               -> STM.TQueue DM.McpResponse
               -> STM.TMVar (Maybe Handle)
               -> Int
               -> IOTask ()
socketReadTask cmdDat resQ handleTMVar size = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketReadTask run. " ++ show size

  STM.atomically (STM.readTMVar handleTMVar) >>= \case
    Nothing -> do
      hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Socket.DS.Core.socketReadTask: socket is not connected."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "socket is not connected."
    Just hdl -> do
      output <- DM.readHandle hdl _SOCKET_READ_WAIT_MSEC size
      DM.toolsCallResponse resQ jsonRpc ExitSuccess (DM.bs2strUTF8 output) ""

  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketReadTask end."

  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentSocketReadCommandData
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)

-- |
--
genSocketReadByteTask :: DM.AgentSocketReadByteCommandData -> AppContext (IOTask ())
genSocketReadByteTask cmdData = do
  let argsBS = DM.unRawJsonByteString $ cmdData^.DM.argumentsAgentSocketReadByteCommandData
  resQ        <- view DM.responseQueueDomainData <$> lift ask
  handleTMVar <- view handleAppData <$> ask
  argsDat     <- liftEither $ eitherDecode argsBS
  let size       = argsDat^.lengthSocketReadByteToolParams
      actualSize = min size _READ_BUFFER_SIZE
  return $ socketReadByteTask cmdData resQ handleTMVar actualSize

-- |
--
socketReadByteTask :: DM.AgentSocketReadByteCommandData
                   -> STM.TQueue DM.McpResponse
                   -> STM.TMVar (Maybe Handle)
                   -> Int
                   -> IOTask ()
socketReadByteTask cmdDat resQ handleTMVar size = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketReadByteTask run. " ++ show size

  STM.atomically (STM.readTMVar handleTMVar) >>= \case
    Nothing -> do
      hPutStrLn stderr "[ERROR] PMS.Infra.Agent.Socket.DS.Core.socketReadByteTask: socket is not connected."
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "socket is not connected."
    Just hdl -> do
      output <- DM.readHandle hdl _SOCKET_READ_WAIT_MSEC size
      DM.toolsCallResponse resQ jsonRpc ExitSuccess (DM.bytesToHex output) ""

  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketReadByteTask end."

  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentSocketReadByteCommandData
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)

-- |
--
_SOCKET_READ_WAIT_MSEC :: Int
_SOCKET_READ_WAIT_MSEC = 100

---------------------------------------------------------------------------------
-- | CR-07 / CR-12: agent-socket-write implementation with invalidPatterns check.
-- The invalidPatterns check is performed here in the gen function (AppContext),
-- consistent with how genProcRunTask performs the allowedCmds check.
genSocketWriteTask :: DM.AgentSocketWriteCommandData -> AppContext (IOTask ())
genSocketWriteTask cmdData = do
  let argsBS = DM.unRawJsonByteString $ cmdData^.DM.argumentsAgentSocketWriteCommandData
  resQ            <- view DM.responseQueueDomainData <$> lift ask
  handleTMVar     <- view handleAppData <$> ask
  invalidPatterns <- view DM.invalidPatternsDomainData <$> lift ask
  argsDat         <- liftEither $ eitherDecode argsBS
  let str           = argsDat^.dataSocketWriteToolParams
      appendNewline = argsDat^.appendNewlineSocketWriteToolParams
  -- CR-12: invalidPatterns check (pure check, consistent with eitherDecode validation)
  case DM.checkInvalidPatterns invalidPatterns str of
    Just pat -> throwError $
      "agent-socket-write: rejected. input matches invalidPatterns: " ++ show pat
    Nothing  -> return ()
  return $ socketWriteTask cmdData resQ handleTMVar str appendNewline

-- |
--
socketWriteTask :: DM.AgentSocketWriteCommandData
               -> STM.TQueue DM.McpResponse
               -> STM.TMVar (Maybe Handle)
               -> String
               -> Maybe Bool
               -> IOTask ()
socketWriteTask cmdDat resQ handleTMVar str appendNewline = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketWriteTask run."

  STM.atomically (STM.readTMVar handleTMVar) >>= \case
    Nothing ->
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "socket is not connected."
    Just hdl -> do
      -- Apply appendCRLF (auto-append newline) unless appendNewline is explicitly False.
      let payload = if appendNewline == Just False
                      then str
                      else DM.appendCRLF str
          bs = TE.encodeUtf8 (T.pack payload)
      hPutStrLn stderr $ "PMS.Infra.Agent.Socket.DS.Core.socketWriteTask " ++ show bs
      DM.writeHandle hdl bs
      DM.toolsCallResponse resQ jsonRpc ExitSuccess "" ""

  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketWriteTask end."

  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentSocketWriteCommandData
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)

---------------------------------------------------------------------------------
-- | CR-07: agent-socket-write-byte implementation
--
genSocketWriteByteTask :: DM.AgentSocketWriteByteCommandData -> AppContext (IOTask ())
genSocketWriteByteTask cmdData = do
  let argsBS = DM.unRawJsonByteString $ cmdData^.DM.argumentsAgentSocketWriteByteCommandData
  resQ        <- view DM.responseQueueDomainData <$> lift ask
  handleTMVar <- view handleAppData <$> ask
  argsDat     <- liftEither $ eitherDecode argsBS
  let hex = argsDat^.dataSocketWriteByteToolParams
  bs          <- liftEither $ hexToBytes hex
  return $ socketWriteByteTask cmdData resQ handleTMVar bs

-- |
--
socketWriteByteTask :: DM.AgentSocketWriteByteCommandData
                   -> STM.TQueue DM.McpResponse
                   -> STM.TMVar (Maybe Handle)
                   -> BS.ByteString
                   -> IOTask ()
socketWriteByteTask cmdDat resQ handleTMVar bs = flip E.catchAny errHdl $ do
  hPutStrLn stderr $ "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketWriteByteTask run."

  STM.atomically (STM.readTMVar handleTMVar) >>= \case
    Nothing ->
      DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" "socket is not connected."
    Just hdl -> do
      hPutStrLn stderr $ "PMS.Infra.Agent.Socket.DS.Core.socketWriteByteTask " ++ show bs
      DM.writeHandle hdl bs
      DM.toolsCallResponse resQ jsonRpc ExitSuccess "" ""

  hPutStrLn stderr "[INFO] PMS.Infra.Agent.Socket.DS.Core.socketWriteByteTask end."

  where
    jsonRpc = cmdDat^.DM.jsonrpcAgentSocketWriteByteCommandData
    errHdl e = DM.toolsCallResponse resQ jsonRpc (ExitFailure 1) "" (show e)